Backend API Capstone: Design an Evolvable Service Boundary
LESSON
Backend API Capstone: Design an Evolvable Service Boundary
The core idea: an evolvable service boundary aligns ownership, HTTP contract, domain workflow, persistence, errors, and compatibility gates around one set of promises.
Core Insight
Imagine the course platform has grown past one codebase and one client. Reviews started as a table inside a monolith. Now the web app, mobile app, moderation console, analytics pipeline, and a partner integration all care about reviews. The team wants a clearer backend boundary before the next wave of changes: richer ratings, moderation rules, generated SDKs, and stricter compatibility promises.
The tempting answer is to draw a box called review-service and move code into it. That is not enough. A service boundary is not just deployment topology. It is a set of ownership decisions and public promises: which resources exist, who may call them, what requests are valid, which errors mean what, how data is persisted, how lists are traversed, and how the contract can evolve without surprising clients.
The capstone move is to design the boundary from the outside inward and then back out again. Start with the client-visible contract. Translate that contract into boundary adapters, commands, use cases, entities, repositories, and units of work. Then return outward through response DTOs, errors, OpenAPI schemas, SDKs, compatibility gates, logs, traces, and operational checks.
The central trade-off is ownership clarity versus coordination cost. A clean boundary makes the review domain easier to reason about, test, and evolve. But every boundary also creates integration work: clients need contracts, other services need APIs, data ownership must be explicit, and releases need compatibility discipline. The right design earns that cost by making the system easier to change safely.
Boundary Decision Map
Before designing endpoints, decide what the boundary owns. A useful first pass is:
| Question | Boundary decision for reviews |
|---|---|
| What is the resource? | A learner's review of a course |
| What is authoritative here? | Review text, rating, review lifecycle, duplicate-review rule |
| What is referenced but not owned? | Course identity, learner identity, enrollment status |
| Who calls it? | Web, mobile, moderation tool, partner SDK |
| What must remain stable? | Public DTOs, error types, list traversal semantics, SDK method contracts |
| What can change internally? | Persistence schema, repository implementation, moderation workflow internals |
This map prevents two common mistakes. The first is making the service too small: it becomes a CRUD wrapper over a table and cannot enforce review rules. The second is making it too large: it starts owning courses, enrollments, identity, and analytics because they are nearby in the workflow.
A good boundary can depend on other facts without owning them. For example, review creation needs to know whether a learner is enrolled in the course. The review boundary can ask an enrollment capability or consume an enrollment snapshot, but it should not silently become the enrollment system. The design should say where that evidence comes from and what happens if it is unavailable.
Public Contract First
Design the smallest stable HTTP surface that supports the real workflows:
POST /courses/{course_id}/reviews create the caller's review
GET /courses/{course_id}/reviews list visible reviews
GET /courses/{course_id}/reviews/me fetch the caller's review
PATCH /courses/{course_id}/reviews/me update the caller's review
DELETE /courses/{course_id}/reviews/me remove or retract the caller's review
The resource model matters. POST /reviews with a free-form course_id in the body can work, but nesting under /courses/{course_id} makes the course relationship visible at the boundary. /reviews/me expresses that ordinary learners operate on their own review, not arbitrary review IDs. A moderation console might later get separate endpoints with different authorization semantics.
Define DTOs deliberately:
{
"rating": 5,
"comment": "Clear, practical, and well structured."
}
The request DTO is not the entity. It is accepted client input. The application command can add trusted context:
CreateReviewCommand
user_id: from auth context
course_id: from path
rating: from validated body
comment: from validated body
request_id: from boundary middleware
The response DTO is also a public promise:
{
"id": "rev_901",
"course_id": "42",
"rating": 5,
"comment": "Clear, practical, and well structured.",
"created_at": "2026-06-15T10:03:21Z",
"updated_at": "2026-06-15T10:03:21Z"
}
Every field should earn its place. If clients can branch on id, display rating, or sort by created_at, those fields are contract surface. If the backend wants to add rating_details later, that should be additive and covered by compatibility gates.
Worked Request Path
For POST /courses/42/reviews, the request lifecycle should look like this:
HTTP request
-> trace/request-id middleware
-> authentication
-> path and body validation
-> boundary adapter builds CreateReviewCommand
-> use case checks enrollment and duplicate-review rule
-> repository/unit of work persists review
-> response mapper returns public ReviewResponse
-> contract and telemetry checks record behavior
The use case owns product meaning:
def create_review(command):
if not enrollment_policy.can_review(command.user_id, command.course_id):
raise ReviewRejected("not_enrolled")
try:
review = reviews.create_once(
user_id=command.user_id,
course_id=command.course_id,
rating=command.rating,
comment=command.comment,
)
unit_of_work.commit()
return review
except DuplicateReview:
raise ReviewRejected("review_already_exists")
The HTTP adapter owns translation:
invalid body -> 400 invalid-request
missing token -> 401 unauthenticated
not enrolled -> 403 review-not-allowed
course not found -> 404 course-not-found
duplicate review -> 409 review-already-exists
database timeout -> 503 dependency-unavailable
That translation is not decoration. It is the public failure contract. The OpenAPI spec should document success and expected error responses. The client SDK should expose typed success models and stable error categories. The telemetry path should preserve request ID, trace ID, dependency timings, and internal exception detail without leaking those details to the client.
Lists, Persistence, and Evolution
The list endpoint needs its own design, not just SELECT * FROM reviews. A reasonable public shape is:
GET /courses/42/reviews?sort=newest&rating=5&limit=20&cursor=<opaque>
The response should make traversal explicit:
{
"data": ["ReviewResponse..."],
"page": {
"limit": 20,
"next_cursor": "opaque-token",
"has_more": true
}
}
Internally, the repository can use (created_at, id) as a stable sort key for newest. The cursor should be opaque and bound to the filter/sort context. If a client changes rating=5 to rating=1 while reusing the old cursor, the boundary should reject the request clearly rather than continue a different traversal by accident.
Persistence should be hidden behind a repository and unit of work because the storage design will change. The first implementation may be one relational table with a unique constraint on (user_id, course_id). Later, moderation state, edit history, denormalized counters, or search indexing may appear. The public API should not expose the table layout.
The unique constraint is still part of the design because it enforces the duplicate-review rule under concurrency. The use case can check first, but the database must be the final guard. When the unique constraint fires, the persistence adapter should translate it into review_already_exists, not leak a raw SQL error through the application.
Compatibility and Release Discipline
An evolvable boundary needs release rules. For this service, treat these as protected promises:
ReviewResponse.ratingremains an integer until a versioned or deprecated path exists.problem.typevalues stay stable for documented errors.sortenum values are not removed silently.- cursors remain opaque and accepted for their documented lifetime.
- generated SDK method names and models are checked before release.
- new response fields are additive unless marked as a versioned change.
The release gate can combine several checks:
OpenAPI diff against last published contract
+ provider verification of consumer contracts
+ generated SDK compile/smoke test
+ migration review for breaking changes
This is where many backend designs become real or fail. It is easy to design a nice boundary on a whiteboard. The discipline is proving that each release still honors the boundary clients were told to rely on.
Architecture Review
Use this checklist to evaluate the proposed service boundary:
- Can you say exactly what the review boundary owns and what it only references?
- Are request DTOs, application commands, entities, and response DTOs separate where they need to be?
- Are auth, validation, domain rules, persistence, and response mapping placed in the right lifecycle stages?
- Are expected errors classified by meaning before they become HTTP responses?
- Does the list endpoint define stable sort, cursor, filter, and total semantics?
- Can an OpenAPI diff detect most structural compatibility breaks?
- Are semantic changes, such as changing sort meaning or error meaning, reviewed by humans?
- Can operators connect a client error to traces, logs, dependency timings, and release version?
Now close the lesson and sketch the boundary from memory. Draw the public endpoints on the left, the use cases in the middle, and persistence plus external dependencies on the right. Then mark every public promise that would need a compatibility gate before release.
Operational Failure Modes
Issue: The boundary is just a CRUD wrapper.
Correction: Put real product rules at the boundary: who can review, what counts as a duplicate, which states are visible, and how errors are classified.
Issue: The service owns too much.
Correction: Own review state, not every adjacent fact. Identity, courses, and enrollment may be dependencies with explicit failure semantics.
Issue: The public contract mirrors the database.
Correction: Keep storage behind repositories and response mappers. Public DTOs should be designed for client promises, not table convenience.
Issue: Compatibility is treated as documentation.
Correction: Make OpenAPI diffs, consumer contracts, generated SDK checks, and release gates part of the workflow.
Issue: Observability stops at "request failed."
Correction: Instrument lifecycle stages: auth, validation, enrollment check, duplicate guard, persistence, response mapping, and contract gate outcomes.
Connections
This capstone pulls together the track. REST and GraphQL lessons taught boundary shape. Auth, validation, DTO mapping, request lifecycles, and error semantics taught the internal path from request to response. OpenAPI, list semantics, and contract gates taught how the public surface evolves safely.
It also sets up the next architectural step: service boundaries are organizational promises as much as technical ones. Once another team or client depends on this boundary, ownership, compatibility, observability, and release policy become part of the design.
Resources
- [RFC] HTTP Semantics RFC 9110
- Focus: Revisit method and status semantics when choosing public endpoint behavior.
- [SPEC] OpenAPI Specification
- Focus: Represent the service boundary as paths, operations, schemas, responses, and examples.
- [ARTICLE] Martin Fowler: Consumer-Driven Contracts
- Focus: Connect the public boundary to consumer expectations and provider verification.
- [BOOK] Release It!
- Focus: Think about timeouts, dependency failures, and operational behavior as part of boundary design.
Key Takeaways
- An evolvable service boundary is a set of ownership decisions and public promises, not merely a deployment unit.
- Design from the public contract inward to use cases and persistence, then back outward through errors, schemas, SDKs, observability, and release gates.
- Clear ownership improves evolvability, but it creates coordination costs that must be paid through contracts and compatibility discipline.
- The final test of a backend boundary is whether clients can depend on it and the team can still change it safely.
← Back to Backend and API Architecture