Microservices Architecture Principles
TechLearningPro's commerce code already has a shape. [Layered and Modular Monolith Architecture](/learn/advanced-system-design/layered-and-modular-monolith-architecture) put catalo
Microservices Architecture Principles
Advanced System Design & Software Architecture · Lesson 38 · Microservices and Enterprise Architecture
Lesson Overview
TechLearningPro's commerce code already has a shape. Layered and Modular Monolith Architecture put catalog, checkout, inventory, and payment in one Spring Boot process with module boundaries. Domain-Driven Design Fundamentals gave those modules different languages: a course price, a shopper's order, a seat reservation, and a card capture are not one aggregate. Distributed Transactions and Saga Pattern then showed what happens when the seat, the charge, and the order do not share a commit: local transactions, compensations, and an outbox row. That saga is honest only if each step has an owner. This lesson decides which owners become separate processes, and which stay packages in the deployable you already run.
A microservice, in the sense this course will use, is a process you can deploy on its own, that owns the data for one business capability, and that other processes reach through a published interface. Independent deployment is the test. If a payment-provider SDK upgrade still forces a catalog release, the split has not bought you a service. It has bought you a network hop and a second pipeline. The primers name the idea: What Are Microservices?, Monolith vs Microservices, Service Boundaries, and Service Decomposition. This lesson applies it to a team of about eighteen engineers in the week the classroom fills. Most of the platform stays a modular monolith. Payment becomes a process because the provider credential and the failure domain are already separate. Inventory stays a schema until its deploy cadence or its write hotspot actually diverges. Catalog does not move, because the hot read path is the one Caching Strategies and Cache Invalidation already protects inside one deployable.
The expensive failure is a distributed monolith: several repositories, one shared database, and a feature that still ships as a lockstep release. You pay for partial failure, and you do not get independent change. The second failure is splitting before the compensation exists. A method that used to sit inside one @Transactional boundary becomes two commits with a gap. Lesson 32 is the design for that gap. Extracting the process before the state machine and the idempotent release exist is how a declined card loses a seat or keeps one forever.
Learning Objectives
By the end of this lesson you will be able to:
- Define a microservice by independent deployability and data ownership, and separate that definition from a bounded context and from a modular-monolith package.
- Decide which TechLearningPro capabilities stay in one process for enrollment week, and which one capability leaves.
- Enforce a single writer per fact with PostgreSQL schema grants, so a convention cannot be bypassed by a JDBC URL.
- Recognize a distributed monolith from deploy coupling, shared tables, and synchronous chat on the user path.
- Sequence an extraction so the in-process port, the saga compensations, and the outbox exist before the network call does.
- State the operational cost a small team takes on with each new process: pipeline, on-call, dashboards, and a timeout where a stack trace used to be.
- Refuse a shared persistence library and a cross-service foreign key, and say what you publish instead.
Prerequisites
- Lesson 7's modular monolith: one deployable, modules that do not reach into each other's internals, a dependency direction you can check.
- Lesson 8's bounded contexts and aggregates. A context is a language. This lesson chooses a process only where the language already has an owner.
- Lesson 32's rule that checkout, inventory, and payment commit in their own databases once they are separate, and that
@Transactionalon oneDataSourcedoes not cover a second URL. - Lesson 9's distinction between a fact other modules may hear and a command that must be compensated. Event-Driven Architecture Fundamentals is the in-process version of the event you will later put on a topic.
- Comfort with PostgreSQL roles and
GRANT/REVOKE. The enforcement in this lesson is a privilege, not a wiki page. - The shorter maps, if you want the vocabulary first: Modular Monolith First, When to Split the Monolith, Database-per-Service, Modular Monolith, and Shared Database Anti-Pattern. This lesson assumes those names and spends its time on the TechLearningPro cut.
Detailed Core Concepts
Three boundaries that people collapse into one
A bounded context is a model boundary. Inside checkout, "order" means the shopper's purchase, with snapshotted prices and a saga state. Inside catalog, the same word does not exist; catalog has a course and a price. Lesson 8 drew that line so two teams would stop arguing about one class named Order.
A module is a compile-time and package boundary inside one JVM. Spring Modulith treats each direct sub-package of the application as a module. ApplicationModules.of(CommerceApplication.class).verify() rejects cycles, rejects references to another module's internal packages, and rejects dependencies you did not list in @ApplicationModule(allowedDependencies = …). The public types live in the module's base package. Sub-packages are internal. A checkout class that imports com.techlearningpro.commerce.payment.internal.ProviderClient fails verification. That failure is the point. It happens in a unit test, on one laptop, before anyone rents a second cluster.
A service is a deployable process with its own data store, or at minimum its own schema and role that no other process's credentials can write. The process boundary adds partial failure, a timeout, a separate pipeline, and a contract you can break at runtime. It does not add a new domain concept. If the module boundary was wrong, the service boundary copies the mistake onto the network.
These three are allowed to differ. TechLearningPro keeps catalog, checkout, inventory, and progress as modules in one process, each with its own schema. Payment is a module in the model and a separate process in production, because the provider credential must not sit in the catalog pod and the provider SDK moves on a calendar checkout does not control. The context map does not change when the process split happens. The adapter behind the port changes, from an in-process call to HTTP or to an outbox command. Hexagonal Architecture and Ports & Adapters is what makes that swap local.
What independent deployability actually requires
You can deploy payment on Tuesday without redeploying catalog only if all of the following are true.
The payment binary does not contain catalog types, and catalog does not contain payment's provider client. A shared jar of value types (Money, Sku, TenantId) is a small shared kernel. A shared jar of JPA entities is a shared schema with a Maven coordinate. The second one forces lockstep upgrades whenever a column changes.
Payment's tables have one writer: the payment process. Checkout stores payment_id. It does not UPDATE payment.captures. There is no foreign key from checkout.order_saga to payment.captures, because those tables do not share a catalog, and a foreign key across databases is not a thing PostgreSQL will maintain for you. Referential integrity becomes an explicit fact: the capture id came back from payment, and a missing id is a saga state, not ON DELETE CASCADE.
The contract checkout uses is stable across a payment deploy. Compatible changes (an added optional field) ship alone. Breaking changes wait for the consumer, which is the subject of API Design, Versioning and Contracts. A deploy that requires the caller to ship in the same hour has reintroduced the monolith's release train and kept the network.
The failure of payment is a defined outcome. Checkout already has payment_uncertain from lesson 32. A process split makes that state ordinary, not exceptional. If the product cannot say what the shopper sees when payment times out, you are not ready to put payment on the other side of a socket.
Data ownership
One fact has one writer. The fact can be read by others as a copy: an API response, an event, a projection. The copy is allowed to be stale. The writer is not allowed to be a committee.
| Fact | Writer | What others store |
|---|---|---|
| Course price, title, published flag | Catalog | A price snapshot on the order line, taken when the shopper commits to pay |
| Seat count and reservation | Inventory | reservation_id on the saga |
| Provider reference, capture result | Payment | payment_id on the saga |
| Saga state, shopper-facing total | Checkout | Nothing of the saga row itself |
| Watch position | Progress | Nothing checkout needs in the pay path |
The snapshot is the rule that stops checkout from joining catalog at pay time. The price the learner accepted is checkout's data from that moment. A later catalog edit does not rewrite the order. Progress hears OrderCompleted and does not tell checkout whether the card may be captured. That direction is the architecture. A "quick read" from progress back into the pay path would put a video-scale database on the critical path of money.
Inside one PostgreSQL cluster, ownership is a role:
sqlCREATE SCHEMA payment;REVOKE ALL ON SCHEMA payment FROM PUBLIC;GRANT USAGE ON SCHEMA payment TO payment_app;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA payment TO payment_app;-- checkout_app can see its own schema and nothing in paymentREVOKE ALL ON SCHEMA payment FROM checkout_app;
A test that connects as checkout_app and selects from payment.captures must fail with insufficient privilege. If the test is skipped because "we trust the code review," the next incident will be a report query that became a write. Separate databases give you the same rule by construction, at the cost of a second backup, a second failover, and the temptation to open two DataSource beans and call both inside one method. Lesson 32 already refused that method. Schema-per-module is the step you can take while the process is still shared. Database-per-service is the step you take when the secret boundary, the failure domain, or the restore target differs.
Database Per Service and Database-per-Service state the end state. This lesson's intermediate state is deliberate: one cluster, many schemas, grants that match the future process split, so the extraction does not also have to invent ownership on the day of the move.
When not to split
Do not split because a slide has boxes, or because a company with a thousand engineers runs a thousand services. Split when a driver you can name is more expensive than the operational tax.
The team cannot staff the process. Eighteen engineers and fourteen services is a fraction of a person per on-call rotation once you count leave. Each service wants a pipeline, a dashboard, an alert, and someone who knows where its logs go. A modular monolith has one of each. Team Topology & Conway's Law records the social version: the system will mirror communication. If one team owns every repository, you have one team's communication pattern and many deployables. Conway's observation is not a slogan. It is a prediction that the interface between services will be as awkward as the interface between the people who must change both.
The user path would become a chain of synchronous calls. Catalog page, price, entitlement, progress, recommendation, then render. Five network hops, each with a tail latency, on the page lesson 24 sized for enrollment morning. Those reads belong behind one process and the cache in lesson 26 until a separate team owns a separate read model. Service Granularity warns about the nanoservice. The concrete test here is: count the synchronous calls on the shopper's critical path after the split. If the count went up and no failure domain got isolated, put the code back in-process.
The step has no compensation and must be atomic with its neighbor. Seat decrement and reservation insert are one local transaction in inventory. They should stay that way. Splitting "seat count" from "reservation" into two services recreates the distributed transaction lesson 32 refused. The aggregate is the smallest unit you move.
You need a coordinated schema change every week. Two services that migrate together are one service. Stay in one schema owner until the change rates diverge.
You are extracting to adopt a circuit breaker. Circuit Breaker, Retry and Bulkhead Patterns is how you contain a dependency you already have. It is not a reason to create one. Payment is already a dependency, in-process or not, because the provider is outside your process. The provider's failure domain exists before the split. Catalog's does not.
Keep a capability in the monolith when the driver is "it might scale later." Stateless copies of one deployable scale the way lesson 25 describes. The database you would still share is the bottleneck you have not moved.
What you give up, said plainly
A local transaction across order and inventory. After a process split, that atomicity is gone. You have the saga, the reservation lease, and intermediate states other learners can see.
A single stack trace. A timeout replaces it. You will need a correlation id from the edge through checkout to payment. The gateway lesson, API Gateway and Backend for Frontend, is where the shopper's request enters. The trace does not design the boundary. It makes the boundary operable.
In-process refactoring. Renaming a method becomes a contract change. That is why the port and the module test come first. You want the rename to hurt while it is still a compile error.
The distributed monolith
Symptoms you can check in a week of shipping:
- A single product change opens pull requests in several repositories that must merge in order.
- More than one service logs into the same tables.
- The shopper's request waits on three or more synchronous downstreams that fail together.
- Nobody can describe the owner of a column.
- A "common" module holds entities, Spring Data repositories, and Feign clients, and every service depends on it.
The remedy is not another service. The remedy is to collapse the accidental processes back into modules, or to finish the ownership move so that only one of them writes. Microservices Myths & Anti-Patterns lists the folklore. The test above is the operational one.
Spring Modulith as the gate, not as the microservice
ApplicationModules.of(CommerceApplication.class).verify() is a test. It does not start a second JVM. Spring Modulith can also record event publications for transactional listeners in the same database, and it can republish incomplete ones on restart (spring.modulith.events.republish-outstanding-events-on-restart). That registry is an in-process reliability tool. It becomes lesson 32's outbox only when a different process must observe the fact and a relay publishes it. Treating the registry as if payment had already moved is how teams skip the ownership work and keep a shared database "for now," indefinitely.
Use the verifier to lock the direction: checkout may depend on payment's API package, payment may not depend on checkout, catalog may not depend on either. When payment leaves the process, the forbidden imports are already gone. The new adapter implements the same port.
Architecture and Design Diagrams
mermaidflowchart LRsubgraph monolith [Commerce deployable]catalog[Catalog module and schema]checkout[Checkout module and schema]inventory[Inventory module and schema]progress[Progress module and schema]endpayment[Payment process and database]provider[Card provider]shopper[Shopper and BFF]shopper --> checkoutcheckout --> catalogcheckout --> inventorycheckout -->|port: capture command| paymentpayment --> providercheckout -->|OrderCompleted fact| progress
Catalog, inventory, and progress are in-process calls or in-process events. Payment is the only box with its own deployable. Progress is downstream of a fact. It is not on the path that decides whether the card is captured.
mermaidflowchart TDdriver[Named driver: secret, failure domain, team, or scale]driver --> ready{Compensation and contract exist?}ready -->|no| stay[Stay a module. Build the port and the saga first]ready -->|yes| cost{Team can run another pipeline and on-call?}cost -->|no| staycost -->|yes| path{User path gains a sync hop without isolating a failure?}path -->|yes| staypath -->|no| extract[Extract the process. Keep one writer]
The diagram is a filter, not a maturity ladder. A capability can stay in the monolith for the life of the product. Payment passes the filter because the provider is already an external failure domain, the credential is a secret boundary, and lesson 32 already named the compensation. Catalog fails the filter because a sync hop would sit on the hottest read and no separate team owns it.
Technical Explanation
The port that survives the move
Checkout talks to a port whose method is the command, not the transport.
javapackage com.techlearningpro.commerce.checkout;public interface PaymentCaptures {CaptureReceipt capture(CaptureCommand command);}public record CaptureCommand(UUID orderId, UUID paymentKey, long amountMinor, String currency) {}public record CaptureReceipt(String paymentId, CaptureStatus status) {}public enum CaptureStatus { CAPTURED, DECLINED, UNCERTAIN }
UNCERTAIN is a result the caller must store. It is the same branch as lesson 32: do not release the seat while the provider might have taken the money. The in-process adapter calls the payment module and translates a provider timeout into UNCERTAIN. The later HTTP adapter does the same translation when the socket times out or the circuit is open. Checkout's saga does not grow a second state machine because the transport changed.
Payment's module descriptor allows nobody to depend on its internals:
java@ApplicationModule(allowedDependencies = {})package com.techlearningpro.commerce.payment;import org.springframework.modulith.ApplicationModule;
Checkout is allowed to see payment's API and inventory's API, and nothing else:
java@ApplicationModule(allowedDependencies = {"payment", "inventory"})package com.techlearningpro.commerce.checkout;import org.springframework.modulith.ApplicationModule;
The test is one line, and it fails the build:
java@Testvoid modulesStayAcyclicAndClosed() {ApplicationModules.of(CommerceApplication.class).verify();}
Verification rejects cycles, rejects use of another module's internal packages, and rejects dependencies omitted from allowedDependencies. When you extract payment, you delete the in-process dependency and the annotation entry. If verify() still passes only because someone widened allowedDependencies to "payment :: internal", the review rejects the widening. The named interface, if you need a slice smaller than the base package, is @NamedInterface. Do not publish the provider client through it.
Price snapshot, so catalog is not on the pay path
sqlCREATE TABLE checkout.order_line (order_id uuid NOT NULL,line_no int NOT NULL,course_id uuid NOT NULL,unit_amount_minor bigint NOT NULL,currency char(3) NOT NULL,PRIMARY KEY (order_id, line_no));
unit_amount_minor is copied at the moment the shopper commits. Checkout does not read catalog.course_price inside the pay transaction. Catalog can be down, slow, or mid-deploy, and the capture amount is still the amount the shopper saw. The cost is a stale price if the shopper lingered on the page. The product sets a maximum age on the snapshot and recomputes before pay if the age is exceeded, by calling catalog once, outside the seat lock. That call is a read with a timeout. It is not a distributed transaction. If catalog is down at recompute time, pay fails closed with a clear error. You do not capture "whatever the last cached price was" unless the product explicitly accepts that, and the acceptance is a written decision.
Grants as the test, not the diagram
Run the suite with the checkout role:
sqlSET ROLE checkout_app;SELECT count(*) FROM payment.captures;
PostgreSQL raises permission denied for schema payment. The integration test expects that SQLException and fails if the select succeeds. Payment's tests run as payment_app and expect the opposite against checkout.order_saga. Shared-database accidents show up as green tests that use a superuser. The datasource in the test container must use the same role the process will use in production. A superuser URL in application-test.yml deletes the evidence.
What changes on extraction day
- Payment's schema moves with payment's binary, to a database whose credentials exist only in the payment deployment. The
REVOKEyou practiced becomes physical separation. - Checkout's
PaymentCapturesadapter is HTTP, with the timeouts and bulkhead from lesson 41, or it is an outbox command if capture need not finish inside the shopper's request. TechLearningPro capture stays synchronous from the saga's point of view: the shopper is waiting, and the saga must observe captured, declined, or uncertain before it returns. The HTTP call is still outside any transaction that holds a seat row. Lesson 32's rule does not relax because the call is now remote. - Catalog, inventory, and progress stay. Their module tests still pass. No new Service DNS name appears for them.
- The gateway still has one public route for the shopper. Payment is not a public API. API Gateway and Backend for Frontend is the front door. A second public door onto payment is an attack surface and a second contract to version.
Coupling you keep and coupling you delete
Some coupling is the domain. Checkout must know that payment returns a receipt. That is interface coupling, and it is acceptable. Temporal coupling is the part you budget: the shopper waits on payment, and payment's timeout is shorter than the page's budget. You do not also make the shopper wait on progress, shipping email, or search indexing. Those stay on OrderCompleted, choreographed, as lesson 32 arranged.
Data coupling is the kind you delete. A view in checkout's schema that selects from payment.captures will break the day the schema moves, and it will be wrong before that day whenever payment's transaction has not committed. Store the id. Ask payment, or consume the fact payment emits.
Operational coupling is the kind you measure. If the payment pipeline's "promote" button is disabled until catalog's pipeline is green, write that down as a failed split and fix the contract, or fold the binaries back together.
Team shape
One stream-aligned squad owns checkout and inventory while they share a process, because the saga is one product flow. Payment can be the same squad at this size. A separate squad is justified when the provider integration has its own backlog (new payment methods, reconciliation files) that was starving catalog work. A platform group, even a part-time one, owns the pipeline template and the cluster. They do not own the order aggregate. Team Topology & Conway's Law is the longer treatment. The decision here is smaller: do not invent a squad per box on the target slide. Invent a process where a backlog and a secret already have a boundary.
Practical Implementation or Design Walkthrough
Problem
Enrollment week is close. A proposal on the table splits catalog, checkout, inventory, payment, progress, search, notifications, and billing into eight services before the event. The team has eighteen engineers, one primary on-call, and a checkout saga that already treats payment and inventory as ownership boundaries.
Requirements
- Paying for a course still reserves at most the remaining seats, releases them on a decline, and does not release them on an uncertain capture. Lesson 32's invariants stand.
- A provider SDK upgrade ships without redeploying the catalog or the classroom.
- The catalog read path does not gain a synchronous dependency on payment or progress.
- The provider credential is not mounted into the catalog deployment.
- The team will not staff more than one extra pipeline before the event.
- Cross-schema reads by the wrong role fail in CI.
Constraints
- One PostgreSQL cluster for the commerce monolith this quarter. Payment may have its own database as part of the extraction.
- No
PREPARE TRANSACTIONon the shopper path. - Spring Boot, Spring Modulith verifier in the build,
JdbcClientfor the snapshot write. - The public edge remains the gateway and BFF from lesson 34. This lesson does not add a service mesh.
Decision
Extract payment only. Keep catalog, checkout, inventory, and progress as modules. Search and notifications already consume facts; they do not need to move in the same change, and they must not join the pay transaction.
Steps
- Draw the context map you already have from lesson 8. Mark a single writer on each fact in the table above. If a fact has two writers, stop and name the owner before talking about repositories.
- Add schema grants and the role-based test. Fix every cross-schema query the test exposes by returning a snapshot or an id, not by granting
SELECTto get the test green. - Introduce
PaymentCapturesin checkout's API package. Move the provider client topayment.internal. RunApplicationModules.verify(). - Confirm the saga still stores
UNCERTAINand does not release seats on that status. The extraction does not change this test. If you do not have the test, you are not ready to add a network failure mode that produces the same status. - Deploy payment as its own process with its own datasource role. Point the checkout adapter at payment over HTTP. Keep the adapter's mapping to
CaptureReceiptcovered by the same unit tests the in-process adapter used. - Leave inventory's schema in the monolith. Revisit extraction only if inventory migrations are blocking checkout deploys, or if seat-write capacity must scale separately from order reads. Until then, in-process calls preserve the local transaction around reservation and seat decrement.
Failure handling
Payment unavailable at capture: the adapter returns UNCERTAIN or a typed declined result, and the saga follows lesson 32. Catalog unavailable at browse: the cache serves the last good outline within its TTL, and pay-time recompute fails closed if the snapshot is too old. Inventory bug: it fails the one deployable, which is the cost you accepted by keeping it local. You do not "partially extract" inventory's seat counter while leaving the reservation insert behind. That is an aggregate split, and it has no compensation that is simpler than the local transaction you already have.
Validation
ApplicationModules.verify()fails when checkout importspayment.internal.- The checkout role cannot read
payment.captures. - Two concurrent pays, one seat: one capture, as in lesson 32. The test still uses one database for inventory.
- A payment-process kill during capture yields
UNCERTAINor a reconciliable idempotency key, never a second capture. - Catalog's deployment manifest does not reference the provider secret.
- A load test of the course page shows no call to payment. If the trace contains one, a browse use case has grown a pay-path dependency, and the design has regressed.
Enterprise Real-World Scenarios
Scenario 1: Enrollment week, and the proposal to split the catalog
Context. The course page is the peak. Lesson 23's workload model says almost everyone reads, and a small fraction pay. The catalog is cacheable. A principal engineer proposes extracting catalog "so it can scale" and having checkout call it for every price.
Design. Refuse the extraction this quarter. Scale the existing deployable and the cache. Checkout keeps the price snapshot, so a catalog deploy does not sit inside the capture. Payment still leaves, because the provider outage and the secret are real, and because putting that credential on every catalog replica expands the compliance scope to the whole fleet.
What goes wrong if you ignore it. Catalog's deploy now fails the pay button. A cold cache plus a slow catalog becomes a slow capture. You have added a hop to the hottest path and coupled the thing you were trying to scale to the thing that takes money. The incident review will show a synchronous client in checkout whose timeout is longer than the page budget. That client is the design bug.
Scenario 2: The academies team asks for a database login
Context. TechLearningPro Academies bills employers. Finance wants a nightly extract of orders, with learner emails, course titles, and the provider reference. The fastest path is a read replica login shared with the analyst, plus a join across checkout, catalog, and payment.
Design. Refuse the login. Checkout already emits OrderCompleted with the snapshot total, the course id, and the payment id. A finance projection, owned by the finance context, builds the invoice line. Course titles arrive as data on the event or from a catalog API the projector calls. They do not arrive from a foreign key. Payment's provider reference stays inside payment; the event carries payment_id, and a reconciliation job that needs the provider's file runs as the payment service, then emits a settlement fact. Strategic Design names the relationship: finance is a customer of checkout's published language. The login would make finance a second writer-in-waiting the moment someone "just updates a flag."
Incident this prevents. An analyst's one-off UPDATE during a disputed invoice, run with a role that could reach payment tables, marks a capture refunded in the database and not at the provider. The saga's notion of compensated and the provider's notion of captured diverge, and lesson 32's compensation_failed path never runs because nobody went through it.
Best Practices
- Name the writer of each fact before you name the repository. If you cannot name one writer, the boundary is not ready.
- Keep the aggregate's local transaction intact. Move that whole aggregate, or move nothing.
- Put a port on the capability you might extract, and verify modules in CI, while everything is still one process.
- Enforce schemas with roles that match production. Test as those roles.
- Snapshot foreign data that must stay stable for a decision (the price at pay time). Store ids, not joined rows, for everything else.
- Extract where a secret, a failure domain, or a genuinely separate deploy cadence already exists.
- Leave the hot read path in the process that renders it until a separate team and a cache strategy exist.
- Measure a split by whether you can deploy it alone. A coordinated release train means the split failed.
- Keep the public surface at the gateway. Internal services do not each grow a public versioning policy.
- Write the compensation and the uncertain-payment test before you add the network that will produce the uncertainty.
- Prefer one shared kernel of value types over a shared kernel of entities and repositories.
- Revisit a "later" extraction on a calendar. A temporary shared table becomes the architecture if nobody schedules the decision again.
Common Mistakes and Anti-Patterns
Splitting by technical layer. A service called persistence and a service called validation have no business capability, no single writer, and no independent reason to deploy. They will chat on every request. Split by the capability and the language, the way the context map already did.
The shared entity jar. commerce-domain exporting @Entity class Order to three services means three deployables and one schema lifecycle. A column addition is a coordinated release. Keep entities inside the writer. Publish a DTO or an event schema at the edge of the module.
Extracting the nanoservice. "Coupon calculator" as a network service, called synchronously from checkout, adds a failure domain to a pure function. A pure function belongs in a library or in checkout's module. Service Granularity describes the smell. The repair is to put the function back.
Shared database, "just for reads." Reads become writes the first time a bugfix is easier as an update. Grants drift. The replica login in scenario 2 is this pattern with a reporting excuse. Publish a fact or an API.
Distributed monolith by release calendar. Six repositories, one launch ticket, merge order written on a whiteboard. You have the operational cost and none of the independence. Collapse or finish the contract so the order of merges stops mattering.
Moving a process before the saga can survive the move. The day the in-process call becomes HTTP, timeouts begin. If UNCERTAIN is not a status you store and reconcile, the first provider blip double-sells a seat or forgets a capture. The network is not the place to discover the missing state.
God orchestrator with two datasources. Checkout "temporarily" opens inventory's database to decrement seats and also calls payment over HTTP. You reassembled the distributed write, without 2PC and without a single owner. Send the command. Let inventory commit.
Troubleshooting and Design Review
| Symptom | Evidence | Likely cause | Action |
|---|---|---|---|
| Every feature touches two repositories | Pull requests linked as "merge together" | Boundary cuts a use case that still changes as one | Fold back into modules, or finish the contract so one side can ship alone |
Checkout SQL mentions payment. | Datasource URL, statement logs | Shared schema, role too strong | REVOKE, move the read to an id or a snapshot, fix the test role |
| Course page latency tracks payment | Trace shows a client call on GET | Browse path depends on the pay service | Remove the call. Price comes from cache and the snapshot |
| Provider secret on catalog pods | Deployment manifests | One image, or one shared config volume | Separate the payment deploy and the secret scope |
| Seat count corrupt after a new "inventory service" | Two writers, no reservation predicate | Aggregate split across processes | Return the decrement and the reservation to one local transaction |
| Modulith test green, production chatty | allowedDependencies includes :: internal | Verification weakened to match the code | Restore the API-only rule and move the type |
| Deploy of payment requires catalog redeploy | Pipeline graph | Shared entity library or a breaking contract with no version | Stop the shared entities; apply the compatibility rules in lesson 40 |
| Cross-schema test passes locally | Test uses the migration superuser | The grant is untested | Run the assertion as checkout_app |
Design review questions: Who writes this table? Which role can connect to it? What does the shopper's request wait on, synchronously? What is the compensation if that wait times out? Can we deploy this binary while its caller stays on yesterday's version? Which secret would leak if this process is compromised? If the answer to the last question is "the provider credential and also the catalog," the process boundary is still wrong.
Interview Questions and Answers
Q1. What do you require before you call something a microservice?
A process you can deploy without redeploying its callers, and data for one capability that only that process writes. Callers keep identifiers and snapshots, not foreign keys into its tables. A bounded context or a package can exist without those two properties. They are the design. The service is the packaging you add when a secret, a failure domain, a team, or a proven scale limit makes the packaging worth the timeout and the extra pipeline. A repository split with a shared database meets neither requirement.
Q2. How is that different from a bounded context?
A bounded context is a language and a model. Checkout's "order" and catalog's "course" can live in one process forever and still be two contexts, which is what lessons 7 and 8 set up. A microservice is one way to deploy a context, with its own data and its own release. You can put two contexts in one service if one team owns both and they share a failure domain. You can, unwisely, split one aggregate across two services. The context tells you where the language changes. The aggregate tells you the smallest piece that must commit together. The service tells you what fails and ships as a unit. Bounded Contexts and Bounded Contexts & DDD develop the language side. This lesson refuses to treat a network boundary as a substitute for it.
Q3. Why does TechLearningPro keep a modular monolith for catalog and extract payment?
Catalog is the hot read, it is cacheable, and putting it on a synchronous call from the page or from pay adds a failure domain you do not have. Payment already depends on an external provider, already holds a credential that must not be on every pod, and already has a saga status for decline and uncertainty. The team can run one additional pipeline. It cannot run eight before enrollment week. The modular monolith keeps local transactions for inventory and a single deployable for the read path. Modular Monolith First argues the sequence. The snapshot and the grant tests are what make the sequence real in this codebase.
Q4. What is a distributed monolith, and how would you detect it in a sprint?
Several deployables that still change, fail, and ship as one. Detection is concrete: merge-order notes between repositories, more than one service with credentials to the same tables, a shopper request that synchronously chains through services which have no separate failure policy, and a shared library of persistence entities. The repair is either to collapse them into modules or to give each fact one writer and a contract that can move independently. Adding a service mesh does not fix shared tables.
Q5. How do you enforce data ownership inside one PostgreSQL cluster?
One schema per capability, REVOKE ALL from PUBLIC, and a role per process that can write only its schema. The application's test datasource uses that role, and a test asserts that checkout cannot select from payment. Superuser migrations run in a different tool path, not in the runtime pool. When you later move payment to its own database, the role boundary is already how the code behaves. Shared Database Anti-Pattern is the failure mode those grants exist to prevent.
Q6. What is wrong with a shared JAR of JPA entities?
It couples the schema lifecycle of every dependent service to one artifact. A nullable column today is a coordinated release tomorrow, and it invites a second service to map the same table and write it. Value types with no persistence annotations are a shared kernel. Entities and Spring Data repositories stay inside the writing module. The published surface is a record the module owns and an event or HTTP representation lesson 40 can version.
Q7. When would you refuse to split even if two teams exist?
When the proposed cut goes through an aggregate that must commit in one local transaction, and the compensation is not designed. Two teams wanting "inventory count" and "reservation" as separate services still share one invariant: you cannot decrement a seat without the reservation row, idempotently. Give both teams modules and schemas in one deployable, or one service with a clear API, until a real driver appears. Team boundaries do not repeal atomicity. If the teams' work always merges together, Conway already told you the service cut is in the wrong place.
Q8. What has to be true on the day an in-process call becomes HTTP?
The caller depends on a port, not on the callee's internals. The saga stores declined and uncertain as different outcomes and reconciles uncertainty with the idempotency key before releasing a seat. The HTTP call sits outside the database transaction that holds inventory locks. Secrets for the callee are not present on the caller. The callee is not suddenly a public internet API; the gateway remains the shopper's front door. Timeouts are shorter than the user-facing budget, which lesson 41 will set. If any of those are missing, stay in-process and finish them. The network will not supply them.
Q9. How does this lesson relate to the order saga and the outbox?
The saga is the process for a checkout that already spans writers. This lesson decides that payment is a separate writer in a separate process, and that inventory's writer may remain a schema in the monolith for now. The outbox, or Spring Modulith's in-process publication registry, is how a committed fact becomes visible to another module. The registry is not a service boundary. The outbox relay from the messaging lesson is what crosses a process boundary without a dual write. You do not event-source the order, and you do not open inventory's tables from the orchestrator, just because payment moved.
Q10. A staff engineer says microservices are the default for a new greenfield product. What do you ship instead, and what do you watch?
Ship one deployable with verified modules, schema-per-capability, and ports at the places a later split might happen. Watch for a secret or a failure domain that is already external (a payment provider), for a module whose migrations block unrelated deploys, and for a load profile the cache and another stateless replica cannot absorb. Those are the extraction tickets. Watch the synchronous call count on the shopper path and the number of pipelines the on-call can actually run. When to Split the Monolith lists drivers. Your watch list is the local version of that page, tied to enrollment week rather than to a target diagram.
Hands-On Exercises
Exercise 1: Cut the commerce deployable
Problem. You have catalog, checkout, inventory, payment, progress, and notifications in one repository. Enrollment week is three weeks away. The provider credential is in the same Kubernetes secret as the catalog database password.
Tasks.
- Name the single writer for price, seat, capture, saga state, and watch position.
- Choose which one capability becomes its own process now, and write the driver in one sentence. Write one sentence each for two capabilities you refuse to move.
- Specify the PostgreSQL role grants that make a cross-schema read fail for the capability that stays.
- Sketch the port checkout will call for capture, including a status for "provider outcome unknown."
- List the tests that must pass before the HTTP adapter is allowed to replace the in-process one.
Deliverables. A one-page decision record, the grant SQL, the port types, and the test list.
Exercise 2: Distributed monolith review
Problem. A teammate has already created four repositories. All four connect to one database. The course-page handler calls three of them synchronously. Releases ship from a checklist.
Tasks.
- Identify which symptoms above are present and which you still need evidence for.
- Propose either a collapse back to modules or a finished ownership model. Include what happens to the shopper's latency if you keep the synchronous chain.
- Say where a shared entity JAR would show up in the build, and what you would replace it with.
Rubric.
| Area | Weight |
|---|---|
| Writer named per fact, aggregate left intact | 25% |
| Refusal to split is tied to a driver, not a slogan | 20% |
| Grants or equivalent enforcement | 15% |
| Port and uncertain-capture outcome preserved | 20% |
| Distributed-monolith evidence and repair | 20% |
Lesson Summary
A microservice is a process you can ship alone and a dataset only that process writes. A bounded context and a modular-monolith package can exist without either property, and for TechLearningPro they should, for catalog, inventory, and progress, through enrollment week. Payment leaves the process because the provider is already a separate failure domain and the credential must leave the catalog fleet. Schema grants and ApplicationModules.verify() lock ownership and dependency direction while there is still one deployable. The price on an order line is a snapshot, so pay does not join catalog. The saga's declined and uncertain outcomes exist before the call becomes HTTP.
The next lesson takes the payment process you just extracted and answers how checkout finds it, and how its timeouts and feature flags arrive, without baking a host name or a secret into the image. Service Discovery and Configuration Management is that operational half. The contract on the public side of the gateway is lesson 40. The policy for when payment is slow or down is lesson 41.
Further Reading
- Martin Fowler, MonolithFirst, and James Lewis and Martin Fowler, Microservices.
- Sam Newman, Building Microservices, chapters on boundaries, data, and the cost of distribution.
- Melvin Conway, "How Do Committees Invent?" Datamation, 1968. Matthew Skelton and Manuel Pais, Team Topologies, for stream-aligned ownership.
- Spring Modulith reference: verification and application events.
ApplicationModules.verify()and the event publication registry. - PostgreSQL documentation:
GRANTandREVOKE. - Chris Richardson, microservices.io, "Database per service" and "Decompose by business capability."
- This course: Layered and Modular Monolith Architecture, Domain-Driven Design Fundamentals, Distributed Transactions and Saga Pattern, API Gateway and Backend for Frontend.
- Primers: Modular Monolith First, Service Boundaries, Service Decomposition, Shared Database Anti-Pattern.