Spring Boot - Microservices in Java von Grund auf bis zur Produktion
Spring Boot Microservices
backendSpring Boot: Microservices in Java von Grund auf bis zur Produktion
Spring Boot hat die Art und Weise, wie Java-Anwendungen entwickelt werden, revolutioniert. Durch die Beseitigung mühsamer XML-Konfiguration und die Bereitstellung produktionsbereiter Komponenten können sich Entwickler auf die Geschäftslogik konzentrieren, anstatt sich mit der Infrastruktur zu beschäftigen. In diesem Artikel führen wir Sie durch das gesamte Spring-Boot-Ökosystem — vom ersten REST-Endpoint über Sicherheit und Datenzugriff bis hin zur Bereitstellung von Microservices auf Kubernetes.
Was ist Spring Boot und das Spring-Ökosystem?#
Spring Framework ist eines der beliebtesten Frameworks für die Entwicklung von Enterprise-Java-Anwendungen. Es wurde als Alternative zum schwergewichtigen EJB-Modell entwickelt und basiert auf zwei Säulen: Inversion of Control (IoC) und Dependency Injection (DI).
Spring Boot ist eine Schicht über dem Spring Framework, die Folgendes einführt:
- Auto-Konfiguration — automatische Erkennung und Konfiguration von Komponenten
- Eingebettete Server — Tomcat, Jetty oder Undertow direkt in die Anwendung integriert
- Spring Boot Starters — kuratierte Abhängigkeitspakete für gängige Szenarien
- Spring Boot Actuator — Anwendungsüberwachung und -verwaltung
- Spring Boot DevTools — automatischer Neustart und Hot-Reload im Entwicklungsmodus
Das Spring-Ökosystem umfasst Dutzende von Projekten, die wichtigsten sind:
- Spring MVC — REST-Controller und HTTP-Verarbeitung
- Spring Data — vereinfachter Datenbankzugriff
- Spring Security — Authentifizierung und Autorisierung
- Spring Cloud — Werkzeuge für den Aufbau von Microservices
- Spring Batch — Stapelverarbeitung
Auto-Konfiguration und Convention over Configuration#
Einer der Schlüsselmechanismen in Spring Boot ist die Auto-Konfiguration. Fügen Sie einfach den entsprechenden Starter zu Ihrer pom.xml oder build.gradle hinzu, und Spring Boot konfiguriert automatisch alle erforderlichen Beans.
// Das ist alles, was Sie brauchen, um eine Spring-Boot-Anwendung zu starten
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
Die Annotation @SpringBootApplication kombiniert drei Mechanismen:
@Configuration— kennzeichnet die Klasse als Quelle für Bean-Definitionen@EnableAutoConfiguration— aktiviert die Auto-Konfiguration@ComponentScan— durchsucht Pakete nach Komponenten
Die Anwendungskonfiguration erfolgt über die Datei application.yml:
server:
port: 8080
spring:
application:
name: order-service
datasource:
url: jdbc:postgresql://localhost:5432/orders
username: ${DB_USERNAME:admin}
password: ${DB_PASSWORD:secret}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 20
Spring Data JPA — Datenbankzugriff#
Spring Data JPA eliminiert die Notwendigkeit, Boilerplate-Code für CRUD-Operationen zu schreiben. Definieren Sie einfach ein Repository-Interface, und Spring generiert die Implementierung automatisch.
JPA-Entität#
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String customerEmail;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@Column(nullable = false)
private BigDecimal totalAmount;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
// Konstruktoren, Getter, Setter
}
Repository#
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerEmailOrderByCreatedAtDesc(String email);
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.createdAt > :since")
Page<Order> findRecentByStatus(
@Param("status") OrderStatus status,
@Param("since") LocalDateTime since,
Pageable pageable
);
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status);
@Query("""
SELECT new com.example.dto.OrderSummary(
o.status, COUNT(o), SUM(o.totalAmount)
)
FROM Order o
WHERE o.createdAt BETWEEN :from AND :to
GROUP BY o.status
""")
List<OrderSummary> getOrderSummary(
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to
);
}
Spring Data generiert automatisch SQL-Abfragen basierend auf Methodennamen (findByCustomerEmailOrderByCreatedAtDesc) und ermöglicht für komplexere Fälle die Definition von JPQL-Abfragen mit der Annotation @Query.
REST-Controller und API-Design#
Spring MVC bietet ein elegantes Modell für die Erstellung von REST-Controllern. Es ist wichtig, bewährte API-Design-Praktiken zu befolgen: geeignete HTTP-Statuscodes, Eingabevalidierung und konsistente Fehlerantworten.
@RestController
@RequestMapping("/api/v1/orders")
@Validated
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public ResponseEntity<Page<OrderResponse>> getOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) OrderStatus status) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
Page<OrderResponse> orders = orderService.getOrders(status, pageable);
return ResponseEntity.ok(orders);
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
return ResponseEntity.ok(orderService.getOrderById(id));
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
OrderResponse created = orderService.createOrder(request);
URI location = URI.create("/api/v1/orders/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PatchMapping("/{id}/status")
public ResponseEntity<OrderResponse> updateStatus(
@PathVariable Long id,
@Valid @RequestBody UpdateStatusRequest request) {
return ResponseEntity.ok(orderService.updateStatus(id, request));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void cancelOrder(@PathVariable Long id) {
orderService.cancelOrder(id);
}
}
Globale Fehlerbehandlung#
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(a, b) -> a
));
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validierung fehlgeschlagen",
LocalDateTime.now(),
fieldErrors
);
return ResponseEntity.badRequest().body(error);
}
}
Spring Security — JWT und OAuth2#
Spring Security ist ein umfassendes Sicherheitsframework. Moderne Microservice-Anwendungen verwenden typischerweise JWT-Tokens oder das OAuth2-Protokoll.
Sicherheitskonfiguration mit JWT#
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
JWT-Filter#
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
Spring Cloud — Microservice-Infrastruktur#
Spring Cloud bietet eine Reihe von Werkzeugen, die für den Aufbau verteilter Microservice-Systeme unerlässlich sind. Die Schlüsselkomponenten sind Service Discovery, Config Server und API Gateway.
Eureka — Service Discovery#
Eureka ermöglicht es Microservices, sich zu registrieren und einander zu finden, ohne Adressen fest zu kodieren.
Eureka-Server:
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}
# Eureka-Server application.yml
server:
port: 8761
eureka:
client:
register-with-eureka: false
fetch-registry: false
Client (Microservice):
spring:
application:
name: order-service
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true
Spring Cloud Config Server#
Ein zentraler Konfigurationsserver ermöglicht die Verwaltung der Konfiguration aller Microservices von einem einzigen Ort aus (z. B. einem Git-Repository):
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
spring:
cloud:
config:
server:
git:
uri: https://github.com/my-org/config-repo
default-label: main
search-paths: '{application}'
Spring Cloud Gateway#
Das Gateway dient als zentraler Einstiegspunkt zum Microservice-System und übernimmt Routing, Load Balancing und Request-Filterung:
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/v1/orders/**
filters:
- name: CircuitBreaker
args:
name: orderServiceCB
fallbackUri: forward:/fallback/orders
- name: RequestRateLimiter
args:
redis-rate-limiter:
replenishRate: 10
burstCapacity: 20
- id: product-service
uri: lb://product-service
predicates:
- Path=/api/v1/products/**
Spring Boot Actuator — Überwachung und Verwaltung#
Actuator stellt eine Reihe von Endpoints zur Überwachung des Anwendungszustands bereit, was in Produktionsumgebungen unverzichtbar ist.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
probes:
enabled: true
metrics:
tags:
application: ${spring.application.name}
Benutzerdefinierter Health Indicator#
@Component
public class ExternalApiHealthIndicator implements HealthIndicator {
private final RestTemplate restTemplate;
public ExternalApiHealthIndicator(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Override
public Health health() {
try {
ResponseEntity<String> response =
restTemplate.getForEntity("https://api.external-service.com/health", String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return Health.up()
.withDetail("externalApi", "Verfügbar")
.withDetail("responseTime", "OK")
.build();
}
return Health.down().withDetail("externalApi", "Nicht gesund").build();
} catch (Exception e) {
return Health.down()
.withDetail("externalApi", "Nicht verfügbar")
.withException(e)
.build();
}
}
}
Spring Profiles — Umgebungsverwaltung#
Profile ermöglichen es Ihnen, verschiedene Konfigurationen für unterschiedliche Umgebungen zu definieren: Entwicklung, Staging und Produktion.
# application.yml — gemeinsame Konfiguration
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
---
# application-dev.yml
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:h2:mem:testdb
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
logging:
level:
com.example: DEBUG
org.hibernate.SQL: DEBUG
---
# application-prod.yml
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate
show-sql: false
logging:
level:
com.example: WARN
Die Profilaktivierung erfolgt über eine Umgebungsvariable oder einen Startparameter:
java -jar app.jar --spring.profiles.active=prod
# oder
SPRING_PROFILES_ACTIVE=prod java -jar app.jar
Testen — MockMvc und Testcontainers#
Spring Boot bietet ein umfangreiches Test-Ökosystem. Integrationstests mit MockMvc ermöglichen das Testen von Controllern ohne HTTP-Server, während Testcontainers Tests mit einer echten Datenbank in Docker ermöglichen.
Controller-Tests mit MockMvc#
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldCreateOrder() throws Exception {
CreateOrderRequest request = new CreateOrderRequest(
"customer@example.com",
List.of(new OrderItemRequest(1L, 2))
);
OrderResponse response = new OrderResponse(
1L, "customer@example.com", OrderStatus.CREATED,
new BigDecimal("199.98"), LocalDateTime.now()
);
when(orderService.createOrder(any())).thenReturn(response);
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("CREATED"))
.andExpect(header().exists("Location"));
}
@Test
void shouldReturn400ForInvalidRequest() throws Exception {
CreateOrderRequest request = new CreateOrderRequest("", List.of());
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.fieldErrors").exists());
}
}
Integrationstests mit Testcontainers#
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
}
@Test
void shouldCreateAndRetrieveOrder() {
CreateOrderRequest request = new CreateOrderRequest(
"test@example.com",
List.of(new OrderItemRequest(1L, 3))
);
ResponseEntity<OrderResponse> createResponse = restTemplate
.postForEntity("/api/v1/orders", request, OrderResponse.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(createResponse.getBody()).isNotNull();
Long orderId = createResponse.getBody().id();
ResponseEntity<OrderResponse> getResponse = restTemplate
.getForEntity("/api/v1/orders/" + orderId, OrderResponse.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().customerEmail()).isEqualTo("test@example.com");
}
}
Docker und Kubernetes — Produktionsbereitstellung#
Dockerfile mit Multi-Stage-Build#
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY gradle/ gradle/
COPY gradlew build.gradle settings.gradle ./
RUN ./gradlew dependencies --no-daemon
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test
# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
Kubernetes-Deployment#
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
labels:
app: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: registry.example.com/order-service:1.0.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "prod"
- name: DB_HOST
valueFrom:
secretKeyRef:
name: db-credentials
key: host
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 60
periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 8080
type: ClusterIP
Performance-Tuning#
Die Leistung von Spring-Boot-Anwendungen hängt von vielen Faktoren ab. Hier sind die wichtigsten Optimierungsbereiche.
Verbindungspools mit HikariCP#
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
max-lifetime: 1200000
leak-detection-threshold: 60000
Caching mit Spring Cache#
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return manager;
}
}
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public ProductResponse getProduct(Long id) {
return productRepository.findById(id)
.map(this::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("Produkt nicht gefunden: " + id));
}
@CacheEvict(value = "products", key = "#id")
public ProductResponse updateProduct(Long id, UpdateProductRequest request) {
// Produkt aktualisieren
}
@CacheEvict(value = "products", allEntries = true)
@Scheduled(fixedRate = 3600000)
public void evictAllProductsCache() {
// Automatische Cache-Bereinigung jede Stunde
}
}
JPA-Abfrageoptimierung#
// N+1-Problem — Lösung mit EntityGraph
@EntityGraph(attributePaths = {"items", "items.product"})
@Query("SELECT o FROM Order o WHERE o.customerEmail = :email")
List<Order> findOrdersWithItems(@Param("email") String email);
// Projektionen — nur benötigte Spalten abrufen
public interface OrderSummaryProjection {
Long getId();
String getCustomerEmail();
OrderStatus getStatus();
BigDecimal getTotalAmount();
}
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<OrderSummaryProjection> findSummariesByStatus(@Param("status") OrderStatus status);
Wann sollte man Spring Boot wählen?#
Spring Boot ist eine ausgezeichnete Wahl, wenn:
- Sie Enterprise-Anwendungen entwickeln — ein ausgereiftes Ökosystem mit Tausenden von Bibliotheken
- Sie Microservices benötigen — Spring Cloud bietet umfassende Unterstützung
- Ihr Team Java kennt — die Lernkurve von Spring Boot ist sanft
- Leistung wichtig ist — die JVM bietet hervorragende Leistung für langlebige Prozesse
- Sie Enterprise-Sicherheit benötigen — Spring Security ist der Branchenstandard
- Integration mit Legacy-Systemen — Java hat die breiteste Palette an Konnektoren
Ziehen Sie Alternativen (Quarkus, Micronaut) in Betracht, wenn Startzeit oder Speicherverbrauch in Serverless-Umgebungen Priorität haben.
Zusammenfassung#
Spring Boot ist eine leistungsstarke Plattform, die praktisch jeden Aspekt der Entwicklung moderner Anwendungen abdeckt:
- Auto-Konfiguration reduziert Boilerplate drastisch
- Spring Data JPA vereinfacht den Datenzugriff
- Spring Security bietet Sicherheit auf Enterprise-Niveau
- Spring Cloud liefert Microservice-Infrastruktur
- Actuator ermöglicht Produktionsüberwachung
- Testcontainers erlauben realistische Integrationstests
Der Schlüssel zum Erfolg liegt darin, das Ökosystem zu kennen und bewährte Praktiken von Anfang an im Projekt anzuwenden.
Benötigen Sie Unterstützung bei Java-Projekten?#
Bei MDS Software Solutions Group sind wir auf die Entwicklung von Enterprise-Anwendungen mit Spring Boot und Microservices spezialisiert. Wir bieten:
- Design von Microservice-Architekturen
- Spring-Boot-Anwendungsentwicklung von Grund auf
- Migration von Monolithen zu Microservices
- Docker- und Kubernetes-Bereitstellungen
- Leistungs- und Sicherheitsaudits
- Technischen Support und Schulungen
Kontaktieren Sie uns, um Ihr Projekt zu besprechen!
Team von Programmierexperten, die sich auf moderne Webtechnologien spezialisiert haben.