What a practical Spring Boot microservices architecture looks like
A practical starting architecture is deliberately small: two to five services, each drawn around a business capability such as orders, billing or catalog, and each the sole owner of its data. Every service is a stand-alone Spring Boot application packaged as an executable jar with an embedded web server. It has its own database, or at least its own schema with credentials no other service holds. If two services need the same table, the boundary is wrong. Shared tables couple deployments and turn schema changes into cross-team negotiations.
Services talk to each other over plain HTTP REST APIs with explicit request and response contracts. They locate each other through configuration, a base URL per dependency, rather than a discovery server. Configuration lives with each service as typed configuration properties, with environment variables overriding it at deploy time. Health and metrics come from Spring Boot's built-in production-ready features, so every service exposes the same endpoints in the same shape.
What this setup leaves out is intentional: no config server, no service registry, no API gateway, no distributed tracing stack. Each of those components is useful, but each is another system to run, secure and upgrade. Early on, uniform conventions do more work than added infrastructure. Every service should be built the same way, report errors the same way, bind configuration the same way and report health the same way. When services are uniform, adding a gateway or tracing later is a mechanical change applied once. When each service has drifted in its own direction, the same change becomes a rewrite.
clients
| HTTP/JSON
v
+-------------------+ REST (catalog.base-url) +-------------------+
| orders-service |------------------------------>| catalog-service |
| Spring Boot jar | | Spring Boot jar |
| embedded server | REST (billing.base-url) | embedded server |
+-------------------+------+ +-------------------+
| | |
v | v
+-------------+ | +-------------------+ +-------------+
| orders DB | +->| billing-service | | catalog DB |
| own schema, | | Spring Boot jar | | own schema, |
| own creds | | embedded server | | own creds |
+-------------+ +-------------------+ +-------------+
|
v
+-------------+
| billing DB |
| own schema, |
| own creds |
+-------------+
Every service: typed @ConfigurationProperties, overridden by env vars
/actuator/health and /actuator/metrics via Actuator
Not yet: config server, service registry, API gateway, tracing
Three stand-alone Spring Boot services split by business capability, each owning its own database, calling each other over REST using configured base URLs and sharing conventions for configuration and health rather than platform infrastructure.
When to split into services and when a modular monolith is the better start
Microservices are worth it when independent teams need to deploy on their own schedules, or when parts of the system have clearly different scaling or availability needs. Without one of those pressures, the costs arrive before the benefits. Every in-process method call that becomes an HTTP request adds latency and a new failure mode. You need timeouts, retries and a plan for when a dependency is slow rather than down. Each service also brings its own pipeline, dashboards, alerts and on-call surface.
Decide boundaries before writing code. Group behaviour by business capability, such as ordering, billing or inventory, and give each capability sole ownership of its data. If two candidate services must read each other's tables, or every feature changes both, the boundary is wrong. Splitting early on guesses tends to produce chatty services that call each other several times per request and have to be released together. You get a distributed monolith with all of the network costs and none of the independence.
For small teams or domains that are still being discovered, a modular monolith is a sound start. It is one Spring Boot application with strictly separated packages per capability. The Spring Modulith project can verify those module boundaries. Extracting a well-isolated module later is far cheaper than merging services that were split along the wrong lines.
| Concern | Modular monolith | Microservices |
|---|---|---|
| Team size | One or a few teams | Several autonomous teams |
| Deployment | Single artifact, one release | Independent releases per service |
| Data consistency | Local transactions | Eventual consistency across services |
| Operational load | One app to run and monitor | Many apps, plus network and tracing concerns |
Bootstrapping each service with consistent conventions
If you're going to run many services, they should all start out alike. Generate each one with Spring Initializr (Spring Boot links to it from the project page). Pick the same build tool, Java version and base package scheme every time. Add only the starters the service needs: spring-boot-starter-web for a REST API and spring-boot-starter-validation for request checks. Add spring-boot-starter-actuator from day one. Starters bring in a dependency set that is tested together, so you don't have to pick compatible versions of each library yourself.
Use the same package layout in every repository: api, domain, persistence, config. An engineer who moves between services can then find things without asking. Put build rules like compiler flags, test tasks and plugin versions in one shared parent POM or Gradle convention plugin. Don't copy them into each build file.
Package every service as an executable jar with the embedded server (Maven's repackage goal or Gradle's bootJar task) and start it with java -jar. Build that one artifact once and promote it through every environment unchanged. Configuration handles the differences between environments, so you never rebuild per environment.
Auto-configuration wires in a data source, JSON mapping and an error handler before you write any code. It can also wire in things you didn't expect. To see what it applied and why, start the app with --debug to print the condition evaluation report, or call Actuator's conditions endpoint.
Shared libraries are tempting because they keep things consistent. But a shared "common" jar holding DTOs or domain logic ties your services together, and one change can force a coordinated release across every team. Share build conventions and narrow technical helpers, not business code.
# Build the executable jar once. With spring-boot-maven-plugin, 'package' runs 'repackage'.
# Gradle equivalent: ./gradlew bootJar (output goes to build/libs/)
./mvnw clean package
# Use this same artifact in every environment. Only the configuration changes.
JAR=target/orders-service-0.0.1-SNAPSHOT.jar
# Local run. --debug prints the condition evaluation report at startup,
# which shows each auto-configuration that was applied or skipped, and why.
java -jar "$JAR" --debug
# Staging run. The jar is the same and only the profile and settings differ.
# DB credentials come from the environment and are never baked into the build.
SPRING_PROFILES_ACTIVE=staging \
SPRING_DATASOURCE_URL=jdbc:postgresql://orders-db.staging.internal:5432/orders \
SPRING_DATASOURCE_USERNAME=orders_app \
SPRING_DATASOURCE_PASSWORD=change-me-placeholder \
java -jar "$JAR" &
# Inspect what auto-configuration wired in while the app runs.
# Only 'health' is exposed over HTTP by default, so expose 'conditions'
# on purpose, and only on a port that isn't public.
java -jar "$JAR" \
--server.port=8080 \
--management.server.port=9001 \
--management.endpoints.web.exposure.include=health,conditions &
# Once the app is up, the response lists positive matches, negative matches
# and unconditional classes for each auto-configuration class.
curl -s
This example builds one executable jar, runs it unchanged with different configuration in each environment, and uses --debug or Actuator's conditions endpoint to see which auto-configurations were applied.
Designing the REST API: DTOs, validation and a consistent error format
Each service exposes its own resources, and the URL structure should reflect the domain, not the database. An order service owns /orders and /orders/{id}. Actions that don't map cleanly to CRUD become sub-resources, such as POST /orders/{id}/cancellation, rather than verbs in the path.
Keep DTOs separate from entities
Controllers should accept and return DTOs, ideally Java records, and never JPA entities. Returning an entity couples your public contract to your table layout. Renaming a column then becomes a breaking API change, lazy-loaded associations can trigger extra queries during serialization, and clients can set fields they should never touch, such as id or status. Use a separate request and response type per operation. A CreateOrderRequest and an OrderResponse can evolve independently, and the mapping between them and the entity stays explicit in one place.
Validate at the boundary
Annotate request DTOs with Bean Validation constraints such as @NotBlank, @Positive and @Size, and mark the controller parameter with @Valid. Invalid input is rejected before it reaches service code, so the domain layer can assume well-formed data.
One error shape everywhere
Clients calling several services shouldn't need a different error parser for each one. Spring Framework 6 includes ProblemDetail, which implements RFC 9457 (Problem Details for HTTP APIs). A @RestControllerAdvice class can translate MethodArgumentNotValidException, not-found cases and unexpected failures into that format, with field errors added as an extension property. Following the earlier advice against shared jars, agree on this shape as a documented contract. Each service then implements its own small handler instead of depending on a shared library.
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
// Thrown by service code when an order does not exist; mapped to 404 below.
class OrderNotFoundException extends RuntimeException {
OrderNotFoundException(long id) { super("Order " + id + " was not found"); }
}
// Each service has its own small handler that produces the agreed RFC 9457 shape.
@RestControllerAdvice
public class ApiExceptionHandler {
// 400: @Valid failures on request DTOs, with one message per invalid field.
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed");
problem.setTitle("Invalid request");
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(fe -> fe.getField(),
fe -> fe.getDefaultMessage() == null ? "invalid" : fe.getDefaultMessage(),
(first, second) -> first)); // keep the first message if a field fails twice
problem.setProperty("errors", errors); // extension property defined in the shared contract
return problem;
}
// 404: a known domain case, so the message is safe to return to clients.
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
// 500: unexpected failures; log the details on the server side and never return internals.
@ExceptionHandler(Exception.class)
ProblemDetail handleUnexpected(Exception ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
}
}
A @RestControllerAdvice that returns Spring's ProblemDetail (RFC 9457) for validation errors, not-found cases and unexpected failures, and adds field-level validation messages as an errors extension property.
Typed configuration properties, profiles and environment variables
Configuration deserves the same discipline as the error format. In most services, configuration starts as scattered @Value("${...}") lookups. Each one is a string key with no type checking, so a typo surfaces only at runtime. Instead, group each concern into one @ConfigurationProperties class. For example, an OrdersProperties record bound to the orders prefix can hold a paymentClient with baseUrl, connectTimeout and readTimeout. Annotate the class with @Validated and add constraints such as @NotNull or @Positive. A missing or malformed value then stops startup instead of failing on the first request.
In application.yaml, the matching block sits under orders.payment-client. Durations like 2s bind directly to java.time.Duration. The YAML should carry safe local defaults only. Each environment overrides values through environment variables using relaxed binding: orders.payment-client.base-url becomes ORDERS_PAYMENTCLIENT_BASEURL. Dots become underscores, dashes are dropped, and the name is uppercased. This keeps one immutable jar promotable from CI to production.
Use profiles sparingly, for example application-local.yaml, activated with SPRING_PROFILES_ACTIVE=local. Profiles that encode real environments tend to drift and leak environment knowledge into the artifact.
| Concern | @Value |
@ConfigurationProperties |
|---|---|---|
| Type safety | Per-field conversion, keys are strings | Whole tree bound to typed fields, records supported |
| Validation | Manual checks | Bean Validation at startup via @Validated |
| Discoverability | Keys scattered across classes | One class per prefix; the configuration processor generates IDE metadata |
| Testability | Needs a Spring context or reflection | Construct the record directly in a unit test |
package com.example.orders.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.net.URI;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
// Binds this block from application.yaml (safe local defaults only):
// orders:
// payment-client:
// base-url:
// connect-timeout: 2s # binds to java.time.Duration
// read-timeout: 5s
//
// Each environment overrides values with environment variables (relaxed binding):
// ORDERS_PAYMENTCLIENT_BASEURL=
// ORDERS_PAYMENTCLIENT_READTIMEOUT=3s
//
// Register it with @ConfigurationPropertiesScan on the application class.
@Validated
@ConfigurationProperties(prefix = "orders")
public record OrdersProperties(
// @Valid makes validation continue into the nested record
@Valid @NotNull PaymentClient paymentClient) {
public record PaymentClient(
@NotNull URI baseUrl, // a malformed URI fails binding at startup
@NotNull Duration connectTimeout,
@NotNull Duration readTimeout) {}
}
// In a unit test, build the record directly. No Spring context is needed:
// var props = new OrdersProperties(new OrdersProperties.PaymentClient(
// URI.create(""), Duration.ofSeconds(2), Duration.ofSeconds(5)));
A validated @ConfigurationProperties record for the orders.payment-client prefix, with its YAML defaults, environment variable overrides and a direct construction for unit tests.
Production readiness: Actuator, service-to-service calls and data ownership
Health checks and endpoint exposure
Add spring-boot-starter-actuator and use the liveness and readiness health groups at /actuator/health/liveness and /actuator/health/readiness. Spring Boot turns them on automatically when it detects Kubernetes. Elsewhere, set management.endpoint.health.probes.enabled=true. Keep the two groups apart. A failed liveness check restarts the container. A failed readiness check only stops traffic to it. If a database check sits in liveness, one slow database can restart every pod at the same time. Expose only what you need with management.endpoints.web.exposure.include, usually health, info and metrics, and serve them on a separate management port where you can. Endpoints such as env, configprops and heapdump can reveal credentials and internal details.
Calling other services
Give every synchronous call a connect timeout and a read timeout. Without them, a slow downstream service holds request threads until the caller is out of threads too. Adding retries makes this worse: each retry adds load to a service that is already struggling, so one slow service can take down the services that call it. Retry only idempotent operations, cap the number of attempts and add backoff between them. Pass a correlation ID in a request header and add it to the logging MDC, so you can follow one request through the logs of every service it touches.
Data ownership
Each service owns its own database, and other services reach that data only through its API. That means no joins across services and no transactions that span them. Accept eventual consistency for workflows that cross service boundaries. Add asynchronous messaging when another service needs to react to a change but the caller doesn't need the result before it responds. If the caller needs the result straight away, keep the call synchronous.
Testing strategy per service
Layer the tests to match the risk
Each service should be able to ship on its own, so it needs a test suite that gives confidence without spinning up the whole system. A practical split has three layers.
Slice tests run first and fail fast. @WebMvcTest loads only the web layer: controllers, JSON mapping, validation and your exception handler. You call endpoints through MockMvc and replace the service layer with a mock (@MockitoBean). The example that follows posts an invalid order request and checks two things: that the response is a 400, and that the body matches the error format from section 4. That catches broken validation annotations and serialization changes in seconds. @DataJpaTest does the same job for repositories. It covers query methods, mappings and constraints, and leaves out the rest of the application context.
Integration tests use @SpringBootTest against real dependencies wherever you can run them. Use a real database engine in a disposable container, not an in-memory substitute. Differences in SQL dialect, locking and migration behavior are exactly what an embedded database hides. Keep these tests few and aim them at the paths where wiring matters: migrations, transactions and outbound client configuration.
Contract tests protect the APIs that other services consume. The consumer writes down the requests it sends and the response fields it relies on, and the provider runs those expectations in its own build. If a provider change would break a consumer, the provider's pipeline fails before deployment. That gives you the safety of an end-to-end test without coordinating environments across teams.
Alternatives and the impact on cost and engineering time
Contract tests show the trade-off this section is about: you get a guarantee without adding a shared environment that can break. The same reasoning decides how much platform infrastructure to build before traffic arrives.
| Approach | Development cost | Operational overhead | Engineering time |
|---|---|---|---|
| Modular monolith | Lowest. One build, one deploy | One runtime to monitor | Low, but module boundaries need active review |
| Full platform stack on day one | High. Discovery, config server, gateway and tracing all wired in | Each component is another thing that can fail, needs upgrades and needs alerting | High before any feature ships |
| Minimal shared conventions | Moderate. One template, reused | Grows only as you add services | Low per service once the template exists |
Shared conventions save time because the decisions are made once. Logging format, error schema, configuration layout and health endpoints are settled before a new service starts. New engineers learn one layout and can read every service. During an incident, every service behaves the same way, so debugging is predictable.
Illustrative estimate (assumed numbers, not a benchmark). The cost of deciding each convention separately in every service is:
services × decisions × hours per decision
With 6 services, 10 decisions and 2 hours each, that is 120 hours. A shared template costs a one-time setup plus a small cost per service:
30 hours + (6 × 1 hour) = 36 hours
Plug in your own numbers to compare.
Add each infrastructure component only when a specific problem appears:
- Service discovery: when instance addresses change faster than your platform's DNS or load balancer can track.
- Config server: when one value must change across many services without redeploying them.
- Gateway: when external clients need a single entry point for authentication and routing.
- Distributed tracing: when correlation IDs in logs no longer locate failures across several network hops.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.