Spring Boot
    August 14, 202642 min read

    Building a Spring Boot Microservices Project from Scratch (Complete Guide)

    A complete walkthrough of building a production-ready microservices system with Spring Boot — covering service decomposition, API gateway, service discovery, Docker Compose, inter-service communication, and real code throughout.

    Share

    I've spent the last decade building enterprise systems — first as a developer writing giant Spring monoliths, then as an architect pulling them apart into services, and now as an engineering manager who has to explain to leadership why the rewrite that was supposed to take three months has taken eighteen. Microservices done well are genuinely powerful. Microservices done wrong are a distributed monolith with YAML files.

    This guide walks through building a real microservices system with Spring Boot from first principles. Not a toy. Not a "hello world" with three services that all live in the same IntelliJ project. A structure you could actually evolve in production.

    Why Microservices — and When NOT To

    The canonical reasons are well known: independent deployability, team autonomy, targeted scaling, technology flexibility. I won't repeat them. What I will say is that I've watched teams reach for microservices before they had the operational maturity to run them, and it consistently makes things worse.

    You probably don't need microservices if:

    • Your team is smaller than 6–8 engineers
    • You're still figuring out your domain model (split too early and you'll split wrong, then spend years compensating)
    • You don't have CI/CD that can independently deploy a service in under 10 minutes
    • You don't have distributed tracing, centralized logging, and alerting that works per-service

    The sweet spot is a system that has outgrown a single team's ability to coordinate changes safely, where you can draw a clean seam in the domain model, and where at least one part of the system has materially different scaling characteristics than the rest.

    For this guide, we're building an e-commerce backend: Order Service, Product Service, and Notification Service, sitting behind an API Gateway, discoverable via Eureka.

    Architecture Overview

    graph TD Client["Client (Browser / Mobile)"] Gateway["API Gateway\n(Spring Cloud Gateway :8080)"] Eureka["Eureka Server\n(Service Registry :8761)"] Order["Order Service\n(:8081)"] Product["Product Service\n(:8082)"] Notification["Notification Service\n(:8083)"] OrderDB[("Order DB\nPostgres")] ProductDB[("Product DB\nPostgres")] Client --> Gateway Gateway --> Order Gateway --> Product Gateway --> Eureka Order --> Eureka Product --> Eureka Notification --> Eureka Order --> Product Order --> Notification Order --> OrderDB Product --> ProductDB

    The gateway is the single entry point — clients never talk to services directly. Services register with Eureka on startup, and Feign clients resolve service addresses through the registry at call time. Notification is fire-and-forget from Order (we'll use async calls here, no message broker to keep the scope manageable).

    Project Setup

    I use a Maven multi-module structure. One parent POM manages dependency versions; each service is its own module. This keeps things organized without coupling deployment — each module produces its own JAR and Docker image.

    microservices-demo/
    ├── pom.xml                  ← parent POM
    ├── api-gateway/
    ├── discovery-server/
    ├── order-service/
    ├── product-service/
    └── notification-service/

    Parent pom.xml:

    <project>
      <modelVersion>4.0.0</modelVersion>
      <groupId>in.buildingai</groupId>
      <artifactId>microservices-demo</artifactId>
      <version>1.0.0-SNAPSHOT</version>
      <packaging>pom</packaging>
    
      <modules>
        <module>discovery-server</module>
        <module>api-gateway</module>
        <module>order-service</module>
        <module>product-service</module>
        <module>notification-service</module>
      </modules>
    
      <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
      </parent>
    
      <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2023.0.3</spring-cloud.version>
      </properties>
    
      <dependencyManagement>
        <dependencies>
          <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
          </dependency>
        </dependencies>
      </dependencyManagement>
    </project>

    Service Decomposition

    The hardest part of microservices isn't the technology — it's drawing the right seams. I use Domain-Driven Design bounded contexts as my primary guide. Each service owns its data and its domain logic. No service queries another service's database. If Order Service needs product information, it calls Product Service's API.

    The services in this system:

    • Product Service: CRUD for products, inventory counts. The source of truth for what exists and what it costs.
    • Order Service: Orchestrates order placement — validates with Product Service, persists the order, notifies Notification Service.
    • Notification Service: Sends emails/SMS. Receives events, doesn't initiate business logic.

    This decomposition follows the natural team boundaries: a catalog team owns Product, a commerce team owns Order, and a platform team owns Notification.

    Eureka Service Discovery

    The discovery server is essentially a Spring Boot app with one annotation.

    discovery-server/pom.xml additions:

    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
    </dependency>

    DiscoveryServerApplication.java:

    @SpringBootApplication
    @EnableEurekaServer
    public class DiscoveryServerApplication {
        public static void main(String[] args) {
            SpringApplication.run(DiscoveryServerApplication.class, args);
        }
    }

    application.yml:

    server:
      port: 8761
    
    spring:
      application:
        name: discovery-server
    
    eureka:
      instance:
        hostname: localhost
      client:
        register-with-eureka: false
        fetch-registry: false
        service-url:
          defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

    The register-with-eureka: false and fetch-registry: false tell the server not to treat itself as a client — otherwise it tries to register with itself and logs confusing errors on startup.

    API Gateway

    Spring Cloud Gateway routes incoming requests to the right downstream service. It also handles cross-cutting concerns — auth token validation, rate limiting, CORS — so services don't have to.

    api-gateway/pom.xml additions:

    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>

    application.yml:

    server:
      port: 8080
    
    spring:
      application:
        name: api-gateway
      cloud:
        gateway:
          discovery:
            locator:
              enabled: true
              lower-case-service-id: true
          routes:
            - id: product-service
              uri: lb://product-service
              predicates:
                - Path=/api/products/**
            - id: order-service
              uri: lb://order-service
              predicates:
                - Path=/api/orders/**
    
    eureka:
      client:
        service-url:
          defaultZone: http://localhost:8761/eureka/

    The lb:// prefix tells the gateway to use Eureka-aware load balancing. Spring Cloud LoadBalancer (included transitively) resolves product-service to a live instance address at request time. You get load balancing for free.

    Product Service

    pom.xml additions:

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>

    Product.java:

    @Entity
    @Table(name = "products")
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class Product {
    
        @Id
        @GeneratedValue(strategy = GenerationType.UUID)
        private String id;
    
        @Column(nullable = false)
        private String name;
    
        private String description;
    
        @Column(nullable = false)
        private BigDecimal price;
    
        @Column(nullable = false)
        private Integer stockQuantity;
    }

    ProductController.java:

    @RestController
    @RequestMapping("/api/products")
    @RequiredArgsConstructor
    public class ProductController {
    
        private final ProductService productService;
    
        @GetMapping
        public List<ProductResponse> getAllProducts() {
            return productService.getAllProducts();
        }
    
        @GetMapping("/{id}")
        public ResponseEntity<ProductResponse> getProduct(@PathVariable String id) {
            return productService.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
        }
    
        @PostMapping
        @ResponseStatus(HttpStatus.CREATED)
        public ProductResponse createProduct(@RequestBody @Valid CreateProductRequest request) {
            return productService.createProduct(request);
        }
    
        @PutMapping("/{id}/stock")
        public ResponseEntity<Void> updateStock(
                @PathVariable String id,
                @RequestParam Integer quantity) {
            productService.updateStock(id, quantity);
            return ResponseEntity.noContent().build();
        }
    }

    application.yml for product-service:

    server:
      port: 8082
    
    spring:
      application:
        name: product-service
      datasource:
        url: jdbc:postgresql://localhost:5433/productdb
        username: ${DB_USERNAME:postgres}
        password: ${DB_PASSWORD:postgres}
      jpa:
        hibernate:
          ddl-auto: update
        show-sql: false
    
    eureka:
      client:
        service-url:
          defaultZone: http://localhost:8761/eureka/

    Order Service with Feign

    Order Service calls Product Service to validate inventory before placing an order. This is where inter-service communication gets real.

    pom.xml additions for order-service:

    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>

    ProductClient.java — the Feign interface:

    @FeignClient(name = "product-service", path = "/api/products")
    public interface ProductClient {
    
        @GetMapping("/{id}")
        Optional<ProductResponse> getProduct(@PathVariable("id") String productId);
    
        @PutMapping("/{id}/stock")
        void updateStock(@PathVariable("id") String productId,
                         @RequestParam("quantity") Integer quantity);
    }

    Feign reads the name attribute, resolves it through Eureka, and makes the HTTP call. From the Order Service's perspective, it's just a method call.

    OrderService.java:

    @Service
    @RequiredArgsConstructor
    @Transactional
    public class OrderService {
    
        private final OrderRepository orderRepository;
        private final ProductClient productClient;
        private final NotificationClient notificationClient;
    
        public OrderResponse placeOrder(PlaceOrderRequest request) {
            // Validate product exists and has stock
            ProductResponse product = productClient.getProduct(request.getProductId())
                .orElseThrow(() -> new ProductNotFoundException(request.getProductId()));
    
            if (product.getStockQuantity() < request.getQuantity()) {
                throw new InsufficientStockException(request.getProductId());
            }
    
            BigDecimal totalAmount = product.getPrice()
                .multiply(BigDecimal.valueOf(request.getQuantity()));
    
            Order order = Order.builder()
                .productId(request.getProductId())
                .productName(product.getName())
                .quantity(request.getQuantity())
                .totalAmount(totalAmount)
                .status(OrderStatus.PLACED)
                .customerEmail(request.getCustomerEmail())
                .build();
    
            orderRepository.save(order);
    
            // Deduct inventory
            productClient.updateStock(request.getProductId(),
                product.getStockQuantity() - request.getQuantity());
    
            // Async notification — fire and forget
            notificationClient.sendOrderConfirmation(
                new OrderNotificationRequest(order.getId(), request.getCustomerEmail(), totalAmount)
            );
    
            return OrderResponse.from(order);
        }
    }

    Enable Feign in the main class:

    @SpringBootApplication
    @EnableFeignClients
    public class OrderServiceApplication {
        public static void main(String[] args) {
            SpringApplication.run(OrderServiceApplication.class, args);
        }
    }

    One thing I've learned the hard way: always configure Feign timeouts explicitly. The defaults are way too permissive for a system where one slow service can cascade.

    spring:
      cloud:
        openfeign:
          client:
            config:
              default:
                connect-timeout: 2000
                read-timeout: 5000

    Docker Compose

    Each service needs a Dockerfile. I use the layered JAR approach with Spring Boot's built-in layer support for faster image rebuilds.

    Dockerfile (same structure for all services, path varies):

    FROM eclipse-temurin:21-jre-alpine
    WORKDIR /app
    COPY target/*.jar app.jar
    EXPOSE 8081
    ENTRYPOINT ["java", "-jar", "app.jar"]

    docker-compose.yml at the project root:

    version: '3.8'
    
    services:
      postgres-order:
        image: postgres:16-alpine
        environment:
          POSTGRES_DB: orderdb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - "5432:5432"
        volumes:
          - order-data:/var/lib/postgresql/data
    
      postgres-product:
        image: postgres:16-alpine
        environment:
          POSTGRES_DB: productdb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - "5433:5432"
        volumes:
          - product-data:/var/lib/postgresql/data
    
      discovery-server:
        build: ./discovery-server
        ports:
          - "8761:8761"
        healthcheck:
          test: ["CMD", "wget", "--quiet", "--tries=1", "--spider",
                 "http://localhost:8761/actuator/health"]
          interval: 10s
          timeout: 5s
          retries: 5
    
      api-gateway:
        build: ./api-gateway
        ports:
          - "8080:8080"
        environment:
          EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
        depends_on:
          discovery-server:
            condition: service_healthy
    
      product-service:
        build: ./product-service
        ports:
          - "8082:8082"
        environment:
          SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-product:5432/productdb
          EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
        depends_on:
          discovery-server:
            condition: service_healthy
          postgres-product:
            condition: service_started
    
      order-service:
        build: ./order-service
        ports:
          - "8081:8081"
        environment:
          SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-order:5432/orderdb
          EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
        depends_on:
          discovery-server:
            condition: service_healthy
          postgres-order:
            condition: service_started
          product-service:
            condition: service_started
    
      notification-service:
        build: ./notification-service
        ports:
          - "8083:8083"
        environment:
          EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://discovery-server:8761/eureka/
        depends_on:
          discovery-server:
            condition: service_healthy
    
    volumes:
      order-data:
      product-data:

    A few things I want to call out here:

    Service startup order matters. Eureka takes 15–30 seconds to fully start. Services that register with it before it's ready will fail with connection errors. The healthcheck + depends_on: condition: service_healthy pattern handles this — but only if your images have wget or curl. Alpine-based images have wget, JRE-only images often don't. Add wget to the Dockerfile if you hit this.

    Don't share databases. Notice each Postgres instance has its own volume and its own port. This is intentional. Shared databases are how microservices turn back into monoliths — through the back door of schema coupling.

    Running the System

    # Build all JARs
    mvn clean package -DskipTests
    
    # Start everything
    docker compose up --build
    
    # Test via gateway
    curl http://localhost:8080/api/products
    curl -X POST http://localhost:8080/api/orders \
      -H "Content-Type: application/json" \
      -d '{"productId":"abc123","quantity":2,"customerEmail":"user@example.com"}'

    Hit the Eureka dashboard at http://localhost:8761 — you should see all four services registered.

    What's Next

    This setup is a solid foundation but not production-complete. The next layers I'd add:

    Resilience. Add Resilience4j circuit breakers to the Feign clients. When Product Service is slow or down, Order Service should fail fast rather than holding threads. Pattern: circuit breaker wrapping the Feign call, with a fallback that returns a cached or degraded response.

    Observability. Integrate Micrometer + OpenTelemetry for distributed tracing. Without this, debugging a request that touches three services is guesswork. Jaeger or Tempo as the backend, Grafana for dashboards.

    Message broker. The fire-and-forget Feign call to Notification Service is fragile. If Notification is down, the notification is lost. Replace with Kafka: Order Service publishes an order.placed event, Notification Service consumes it with at-least-once delivery guarantee.

    Kubernetes. Docker Compose is fine for local dev and small deployments. For anything beyond a handful of instances, Kubernetes gives you self-healing, rolling deployments, and proper resource management. The services are already containerized — Kubernetes manifests are the next step.

    The hardest part of microservices is the organizational discipline to maintain the boundaries you draw at the start. The technology is tractable. Resisting the temptation to make a "quick" cross-service database join when the deadline is close — that's where most microservices architectures quietly become distributed monoliths over time.

    Keep the seams clean. The tech will follow.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments