Start with a modular monolith; extract services only on evidence
A modular monolith gives you clear internal boundaries without the network, consistency, and operational cost of distribution, and it keeps refactoring of boundaries cheap while you are still learning the domain. Extract a service only when a concrete force demands it - independent scaling, independent deployment cadence, or team autonomy.
- Do: keep modules with explicit interfaces and no shared internal state, so a module can later be promoted to a service.
- Don't: split into microservices on day one "to be scalable" when one team and one workload would be far simpler as a monolith.
Specify quality attributes as concrete, testable scenarios
Architecture exists to deliver non-functional properties, and you cannot design for or verify a vague goal. Capture each as a scenario with a stimulus, environment, expected response, and a measurable response measure.
- Do: write "at peak load, a checkout request returns within 300 ms at p99".
- Don't: write "the system should be fast and scalable" and leave it untested and unowned.
Make dependencies point inward toward the domain
Apply the dependency rule shared by Hexagonal, Onion, and Clean Architecture: business rules must not depend on frameworks, UI, or persistence. Define the interfaces the domain needs and implement them in outer adapters.
- Do: declare an
OrderRepositoryport in the domain and a JPA adapter in infrastructure. - Don't: annotate a domain entity with persistence annotations, welding business logic to a specific ORM.
Draw boundaries around business capabilities, not technical tiers
Bounded contexts aligned to business capabilities produce high cohesion and loose coupling; boundaries drawn around technical layers (a "database service", a "logic service") maximise chatty cross-boundary traffic. A boundary is right when most changes stay on one side of it.
- Do: own "Ordering" and "Billing" as separate contexts with their own data.
- Don't: create a shared "entity service" that every other service must call to do anything.
Record significant decisions as ADRs
Architecture knowledge evaporates when the people who hold it leave. An Architecture Decision Record captures the context, the options, the choice, and the consequences as an immutable, version-controlled document next to the code.
- Do: add an ADR when you pick a consistency model, a messaging style, or a boundary.
- Don't: rely on a diagram or a Slack thread to explain why a load-bearing decision was made.
Enforce architectural rules with fitness functions
Diagrams drift from reality because nothing stops a developer from violating them. Encode the rules - layering direction, allowed dependencies, latency budgets - as automated checks that fail the build.
- Do: add an ArchUnit test asserting that
domainnever importsweborpersistence. - Don't: trust a code-review convention alone to preserve the dependency direction over years.
Prefer asynchronous events for "notify" and synchronous calls for "ask"
Synchronous calls couple availability and create latency chains; events decouple in time and improve resilience but add eventual consistency. Match the mechanism to the intent.
- Do: emit an
OrderPlacedevent that Billing and Shipping consume independently. - Don't: have Ordering synchronously call Billing, Shipping, and Notifications in one request, so any of them being down fails the order.
Design every remote interaction to fail
In a distributed system the network will partition, dependencies will slow down, and messages will be redelivered. Treat failure as the normal case, not an exception.
- Do: put a timeout, retry-with-backoff, and circuit breaker on each remote call, and make the operation idempotent so retries are safe.
- Don't: make an unbounded synchronous call and assume the dependency is always fast and available.
Use sagas, not distributed two-phase commit, across services
Two-phase commit blocks under coordinator failure and does not scale across autonomous services. Model cross-service business transactions as sagas: a chain of local transactions with compensating actions.
- Do: implement an order saga where a failed payment triggers a compensating "release reservation" step.
- Don't: wrap calls to three services in one distributed transaction and expect atomicity.
Keep one consistency boundary per aggregate
Strong consistency is cheap inside a single aggregate or database transaction and expensive across them. Keep invariants that must hold instantly within one aggregate, and accept eventual consistency between aggregates and services.
- Do: enforce "an order's total equals the sum of its lines" within the Order aggregate.
- Don't: spread a single invariant across two services and try to keep it strongly consistent with chatty synchronous calls.
Keep the API gateway thin and free of business logic
A gateway is the right place for routing, auth, rate limiting, and TLS, but business logic there becomes a shared bottleneck that every team must coordinate through. Push behaviour into the owning service.
- Do: terminate TLS, authenticate, and rate-limit at the gateway, then route.
- Don't: implement order-pricing rules in the gateway because it is "in the middle".
Build observability in from the start
You cannot operate or evolve what you cannot see, and observability is far harder to retrofit. Emit structured logs, metrics tied to your SLIs, and distributed traces with a propagated correlation ID.
- Do: define SLOs, instrument the matching SLIs, and trace requests end to end across services.
- Don't: ship a distributed system whose only diagnostic is unstructured logs on individual hosts.
Replace legacy systems incrementally with the Strangler Fig pattern
Big-bang rewrites are high-risk and frequently fail. Route functionality to the new system feature by feature behind a façade until the old system can be retired.
- Do: intercept traffic at the edge and migrate one capability at a time, keeping both running.
- Don't: freeze the legacy system, rebuild everything in parallel, and attempt a single cutover.
Make architecture evolutionary, deciding the expensive things up front
Decide only what is costly to change later - consistency model, major boundaries, integration style - and defer cheap decisions to the last responsible moment. Protect the few hard constraints with fitness functions and let the rest emerge through refactoring.
- Do: fix the consistency model and module boundaries early, and let detailed structure evolve.
- Don't: attempt a complete big design up front based on requirements you do not yet fully understand.