Microservice Architecture: Lessons Learned from Two Years in Practice
In early 2017, we at encircle360 took the plunge and consistently migrated our project landscape to microservices. Spring Boot as the foundation, Docker as the deployment unit, Kubernetes for orchestration. Two years later, it's time for an honest assessment: what worked, where were we wrong, and what would we do differently today?
This article is not a tutorial and not an architecture recommendation. It is a field report -- with all its rough edges.
Lesson 1: Service Boundaries Are Everything
The most important insight first: the biggest challenge with microservices is not the technology. It is the question of where to draw the boundaries between services. Poorly drawn boundaries lead to a distributed monolith -- you get the disadvantages of both worlds without enjoying the benefits of either.
We experienced this firsthand. In an early project, we split services by technical layers: one service for database access, one for business logic, one for the API. The result was that every domain change affected three services simultaneously and required coordinated deployments. That was worse than the monolith we were trying to replace.
The solution lay in Domain-Driven Design, specifically in the concept of Bounded Contexts. A service should represent a business domain -- orders, customer management, invoicing -- not a technical layer. Only when we had internalized this mindset did microservices play to their strengths: independent deployments, clear team responsibilities, isolated data ownership.
If you're planning microservices, understand your domain first. Not the technology.
Lesson 2: Internal Abstraction in Moderation
A pattern we initially applied to every service: hexagonal architecture with ports and adapters. Every database connection behind an interface. Every external call behind an abstraction. The idea was that we could swap out the database or framework at any time.
In practice, that never happened. Not once did we replace Spring Boot with another framework in a running service. Not once did we swap PostgreSQL for MongoDB. What we had instead were dozens of interfaces with exactly one implementation, making code navigation harder and doubling the effort for every change.
Our shift in thinking: Spring Boot is not an implementation detail that needs to be hidden. It is a mature framework whose annotations and conventions should be embraced. @Service, @Repository, @Transactional -- these are not workarounds but productive tools. Anyone working in a Spring Boot service should be allowed to see that.
That doesn't mean abstraction is generally bad. But it must serve a concrete purpose. An interface for a service that potentially has multiple implementations -- yes. An interface as a wrapper around a Spring Data repository that will never be swapped out -- no.
Lesson 3: Package by Feature, Not by Layer
Closely related to the abstraction topic is the package structure. Our early services looked like this:
com.encircle360.orderservice
├── controller/
├── service/
├── repository/
├── model/
├── dto/
└── mapper/
That works for small services. But as soon as a service covers multiple domain aspects -- such as orders and returns -- it becomes unwieldy. Which controller belongs to which service? Which DTO to which use case?
We transitioned to structuring by features:
com.encircle360.orderservice
├── order/
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ └── Order.java
└── return/
├── ReturnController.java
├── ReturnService.java
├── ReturnRepository.java
└── Return.java
Each domain unit is a package. Everything that belongs together lives together. This not only makes navigation easier but also immediately shows when a service is taking on too many responsibilities -- a signal that splitting it may be worthwhile.
Lesson 4: Integration Tests Beat Unit Tests at Service Boundaries
Early on, we invested a lot of energy in unit tests with mocked dependencies. Every service layer was tested in isolation, every repository method verified with a mock. Test coverage looked impressive, but the tests had a fundamental problem: they tested wiring, not behavior.
When an OrderService is tested with a mocked OrderRepository, you're essentially checking whether the right method calls happen in the right order. Whether the SQL query is correct, whether the transaction boundaries are right, whether JSON serialization works -- all of that remains untested.
Our approach today: integration tests with real dependencies wherever possible. Testcontainers made an enormous difference for us. A PostgreSQL container that spins up in seconds allows you to test database access against a real database. Spring Boot provides the tools with @SpringBootTest and MockMvc to test a service including the HTTP layer.
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class OrderControllerIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:11");
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateOrder() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"product\": \"Widget\", \"quantity\": 5}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists());
}
}
That's slower than a unit test with mocks. But it tests what actually happens in production. A failing integration test indicates a real problem. A failing mock test often just shows that an internal API has changed.
Lesson 5: Distributed Systems Are Genuinely Hard
That sounds like a truism, but the full extent only becomes clear when you're in the thick of it yourself. Two topics kept us particularly busy.
Eventual Consistency. When each service has its own database -- as recommended for microservices -- there are no cross-service transactions. When the Order Service creates an order and the Inventory Service is supposed to reduce stock, either step can fail. Saga pattern, compensating transactions, event-driven architecture -- the solutions exist, but they are complex and error-prone. In a monolith, a single database transaction would have sufficed.
Distributed Tracing. When a request passes through five services and an error occurs at the end, you need to be able to trace the entire path. Without proper tracing, debugging in a microservice architecture is virtually impossible. We rely on Spring Cloud Sleuth for trace ID propagation and the ELK stack for aggregation. It works, but requires discipline: every service must be properly instrumented, and every asynchronous communication must pass along the trace ID.
These problems are solvable. But they are not trivial, and they simply don't exist in a monolith. You should factor this in honestly when making architectural decisions.
Lesson 6: The Modular Monolith as a Starting Point
When we start a new project today, we no longer automatically recommend microservices. Instead, we begin with a modular monolith: a single deployment unit, but internally cleanly separated by domain modules. Clear package boundaries, defined interfaces between modules, separate database schemas per module.
This approach has several advantages. Development is faster because the infrastructure complexity is gone. Refactoring is easier because everything runs in the same process. And when a module actually grows to the point where it should become its own service -- because it needs to scale independently or be maintained by a different team -- extraction is comparatively easy because the boundaries are already cleanly defined.
This is not a step backward. It is the realization that microservices are a tool, not a goal. You introduce them when the pain in the monolith is greater than the complexity of the distributed architecture. Not before.
Pragmatism Over Dogma
If I had to distill the last two years into a single sentence, it would be this: pragmatic architecture beats dogmatic architecture. Every time.
We learned that clean service boundaries matter more than the number of services. That Spring Boot is a feature, not a flaw. That integration tests provide more confidence than a hundred mocked unit tests. And that a well-structured monolith is superior to a poorly designed microservice system in almost every respect.
Microservices are a powerful architecture -- in the right place, with the right team, and for the right reasons. But they are not a silver bullet. And honestly confronting your own mistakes is the best way to get better.
Written by
Patrick HütterFounder & Software Architect
Software architect, engineer and entrepreneur. Patrick has been building products and platforms for over a decade — from enterprise backends and cloud-native infrastructure to AI-powered applications. As founder of encircle360, he combines deep technical expertise with entrepreneurial vision, driving open source projects that create real impact.
You might also like
From Manifest to Production: How ADL, A2A and the Inference Gateway Are Revolutionizing Agent Infrastructure
Jul 5, 2026 · 12 min read
Agent Orchestration with Java: Bringing LLM Agents to Production on the JVM
Jul 4, 2026 · 6 min read
Spring AI: How Java Developers Can Finally Integrate AI Features the Right Way
Mar 25, 2026 · 5 min read