Building Microservices with Spring Boot

Your First Microservice

18 min Lesson 1 of 12

Your First Microservice

A microservice is a small, independently deployable process that owns exactly one business capability, exposes that capability over a network boundary, and can be built, tested, and released without coordination with other services. That definition sounds abstract until you actually write one — so that is exactly what this lesson does.

You already know Spring Boot. A microservice built with Spring Boot is, at its core, a perfectly normal Spring Boot application. What makes it a microservice is a set of design decisions, not a different framework.

The Mental Shift: From Monolith to Single-Responsibility Service

In a monolith, a single deployable artifact contains the order logic, the user logic, the inventory logic, and everything in between. In a microservices architecture, each of those is its own deployable unit. This lesson builds the Product Catalogue Service — a service that does exactly one thing: manage product data. It does not handle orders, users, or anything else.

Why one responsibility per service? When every service has a single bounded context, teams can deploy changes independently, scale individual services under load, and isolate failures. A crash in the Product Catalogue does not cascade to the Order Service. That isolation is the whole point.

Project Setup with Spring Initializr

Go to start.spring.io (or use your IDE's Spring Initializr integration) and create a project with these coordinates:

  • Group: com.example
  • Artifact: product-service
  • Java: 21
  • Spring Boot: 3.3.x
  • Dependencies: Spring Web, Spring Data JPA, H2 Database, Spring Boot Actuator, Validation

Every one of those choices is deliberate. Spring Web gives us the embedded Tomcat and the REST layer. Spring Data JPA and H2 let us persist data without standing up an external database for this first example. Actuator provides the health-check endpoints that orchestration platforms (Kubernetes, ECS) need. Validation lets us enforce API contracts at the boundary.

application.yml — Service Identity First

Rename application.properties to application.yml and give the service a clear identity:

spring: application: name: product-service datasource: url: jdbc:h2:mem:products driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create-drop show-sql: false server: port: 8081 management: endpoints: web: exposure: include: health, info, metrics

The spring.application.name property is not cosmetic. Service-discovery registries (Eureka, Consul), distributed tracing systems (Micrometer Tracing / Zipkin), and log aggregation pipelines all use this name to identify which service produced a given record. Set it correctly from day one.

Always set a non-default port. Running all your services on 8080 locally means only one can start at a time. Assigning distinct ports (8081, 8082, …) during development avoids conflicts and mirrors the separation you will have in production.

The Domain Model

Keep it minimal — a Product with an ID, name, price, and stock count:

package com.example.productservice.domain; import jakarta.persistence.*; import jakarta.validation.constraints.*; import java.math.BigDecimal; @Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank @Size(max = 120) @Column(nullable = false, length = 120) private String name; @NotNull @DecimalMin("0.00") @Digits(integer = 10, fraction = 2) @Column(nullable = false, precision = 12, scale = 2) private BigDecimal price; @Min(0) @Column(nullable = false) private int stockCount; // constructors, getters, setters omitted for brevity }

Notice that BigDecimal is used for price, not double. Floating-point types cannot represent decimal fractions exactly, which produces rounding errors when you add prices — a serious bug in any financial context.

The Repository

Spring Data JPA eliminates boilerplate. Declare an interface; Spring generates the implementation at startup:

package com.example.productservice.domain; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface ProductRepository extends JpaRepository<Product, Long> { List<Product> findByStockCountGreaterThan(int threshold); }

The Service Layer

Keep business logic out of controllers. The service layer enforces rules, coordinates transactions, and is the boundary your unit tests target:

package com.example.productservice.service; import com.example.productservice.domain.Product; import com.example.productservice.domain.ProductRepository; import jakarta.transaction.Transactional; import org.springframework.stereotype.Service; import java.util.List; @Service public class ProductService { private final ProductRepository repo; public ProductService(ProductRepository repo) { this.repo = repo; } public List<Product> listAll() { return repo.findAll(); } public Product findById(Long id) { return repo.findById(id) .orElseThrow(() -> new ProductNotFoundException(id)); } @Transactional public Product create(Product product) { return repo.save(product); } @Transactional public void delete(Long id) { findById(id); // throws 404 if not found repo.deleteById(id); } }

The REST Controller

The controller is deliberately thin. It translates HTTP into service calls and service results into HTTP responses:

package com.example.productservice.web; import com.example.productservice.domain.Product; import com.example.productservice.service.ProductService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/products") public class ProductController { private final ProductService service; public ProductController(ProductService service) { this.service = service; } @GetMapping public List<Product> list() { return service.listAll(); } @GetMapping("/{id}") public Product get(@PathVariable Long id) { return service.findById(id); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Product create(@Valid @RequestBody Product product) { return service.create(product); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { service.delete(id); } }

Error Handling at the Service Boundary

A microservice is a network citizen. When a caller asks for product 999 and it does not exist, returning a raw Spring 500 page is wrong. Map domain exceptions to appropriate HTTP status codes with a @ControllerAdvice:

package com.example.productservice.web; import com.example.productservice.service.ProductNotFoundException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import java.time.Instant; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ProductNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public Map<String, Object> handleNotFound(ProductNotFoundException ex) { return Map.of( "status", 404, "error", "Not Found", "message", ex.getMessage(), "timestamp", Instant.now().toString() ); } }
Never expose stack traces or internal exception messages across a service boundary. They leak implementation details (class names, library versions, SQL) that attackers use to fingerprint your stack. Return a structured, human-readable error body and log the full stack trace server-side only.

Verifying the Service Is Alive

Run the service with ./mvnw spring-boot:run. Spring Boot Actuator exposes GET /actuator/health automatically. In production, your container orchestrator calls this endpoint every few seconds; if it returns anything other than {"status":"UP"}, the instance is taken out of the load balancer rotation and replaced. Get into the habit of checking it locally:

curl http://localhost:8081/actuator/health # {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}

The database component is included because Actuator detects the H2 DataSource and probes it automatically.

What Makes This a Microservice, Not Just a REST App

At this point you have a working REST service. What earns it the "microservice" label is everything you deliberately did not put in it:

  • No user authentication logic — that belongs to an Identity Service.
  • No order processing — that belongs to an Order Service.
  • Its own database schema, owned exclusively by this service.
  • A health endpoint ready for an orchestrator.
  • An application name that will appear in logs and traces.

The upcoming lessons will connect this service to others, add resilience patterns, and deploy it in a container. The foundation you built here — one responsibility, clear API, structured errors, health endpoint — is what everything else builds on.