Stage 7 · Master
Capstone — Assembling Fieldwork End-to-End
Wiring Every Service Together
Trace one task-creation request from the public gateway to Postgres, Kafka, notifications, and cache invalidation without hand-waving any layer away.
The Request We Traced
For the capstone, we picked one request and followed it all the way through the system: a workspace member creates a task inside a project. That sounds almost too ordinary, which is exactly why it is useful. It touches the gateway, JWT auth, tenant scoping, RBAC, tasks-service validation, a Postgres transaction, the outbox publisher, Kafka, notifications, and cache invalidation. If even one layer cheats, the whole thing becomes visible here.
We deliberately did not trace login, because login overemphasizes auth-service. Task creation is better because it forces multiple decisions made across the course to coexist: who establishes tenant context, where authorization is enforced, when side effects become asynchronous, and how read paths learn that a write just happened.
POST /v1/workspaces/ws_42/projects/prj_9/tasks HTTP/1.1
Host: api.fieldwork.dev
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
X-Request-ID: 8fbe73e2-c1fd-43e5-b4b8-50b9d17ee83f
Content-Type: application/json
{
"title": "Document Kafka consumer retry policy",
"description": "Cover backoff, dead-letter routing, and alert ownership.",
"assigneeId": "usr_88",
"labels": ["platform", "docs"],
"dueAt": "2026-07-31T18:00:00Z"
}A task creation request is small enough to read and rich enough to exercise most of the stack. Every downstream example in this lesson hangs off this one payload.
Gateway: Auth, Tenant Context, and RBAC
The gateway owns three things before any domain service sees the request: verifying the JWT, deriving tenant and actor context, and enforcing coarse-grained route authorization. We rejected the alternative of pushing raw tokens into every service because it duplicated cryptographic verification logic, made policy changes harder to roll out, and tempted each service to invent its own slightly different understanding of roles.
After auth, the gateway forwards only the trusted context the tasks service needs. It does not forward the raw JWT as the primary input contract. That kept internal handlers simpler and made test fixtures much easier to write: service tests construct an actor context, not a signed token.
A member role may be allowed to hit 'create task' routes in general and still be forbidden from creating tasks in a project outside their workspace. That second check must happen in the tasks service against its own data, or the gateway becomes a source of stale policy truth.
Tasks Service: From Handler to Application Service
Inside the tasks service, the request becomes a domain command. The handler parses headers into an actor, normalizes payload fields, and asks the application service to enforce project membership and create the task. We rejected fat handlers that directly mixed JSON decoding, authorization queries, inserts, metrics, and event publishing. The capstone request is exactly where that style becomes unreadable.
The Transaction: Write Task and Outbox Together
The core consistency decision in Fieldwork was to write the task row and the event row in the same Postgres transaction. We rejected the common first draft — insert the task, commit, then publish directly to Kafka — because it creates the exact failure window distributed systems punish: the database succeeds, the broker call fails, and now the system has a new task with no downstream signal that it exists.
| Approach | What looks attractive about it | Why Fieldwork rejected or kept it |
|---|---|---|
| Write row, then publish directly to Kafka | Less code, fewer tables, no outbox worker | Rejected because a successful DB commit plus failed publish silently loses downstream side effects |
| Distributed transaction across Postgres and Kafka | Theoretical single-step consistency | Rejected as operationally heavy for a small service boundary and unnecessary given an outbox works well |
| Task row plus outbox row in one transaction | Local atomicity with practical delivery guarantees | Kept because it matches the failure modes we can actually operate |
What it gives is durable intent. A downstream consumer still has to be idempotent, because retries and duplicate deliveries can still happen after the event leaves Postgres.
From Outbox to Kafka to Notifications
Once the transaction commits, a relay process publishes pending outbox rows to Kafka. Notifications-service consumes task.created and turns it into side effects that should not block the original request: notifying the assignee, updating activity feeds, or kicking off email delivery later. That boundary is where the system stops being 'HTTP request plus database write' and becomes an evented backend.
We also rejected synchronous notification calls from the tasks service. That would have made task creation latency depend on whichever downstream side effect was slowest and would have created a stronger runtime dependency graph than the feature required. A task should still be created if email is degraded for five minutes.
The Cache Invalidation Step We Did Not Skip
The write path is not finished when the row commits and the event leaves Kafka. Fieldwork also cached task-list summaries and project counters in Redis. Without invalidation, the system would be correct in Postgres and wrong everywhere users actually looked. The cache step is not an optimization detail. It is part of the correctness story once cached reads exist.
| Side effect | Inline with request | Async or best-effort |
|---|---|---|
| JWT verification | Yes — request cannot proceed without identity | No |
| Project membership check | Yes — domain correctness depends on it | No |
| Task row insert | Yes — primary write | No |
| Outbox record insert | Yes — required for durable downstream intent | No |
| Kafka publish | No | Yes — via outbox relay |
| Notification creation | No | Yes — via consumer |
| Redis invalidation | No rollback on failure | Best-effort with logs and metrics |
What the Full Flow Proved
Tracing the task-creation path end to end made one thing obvious: most of the course's decisions were not about individual tools. They were about where truth lives. Identity truth lives at the gateway, project authorization truth lives in the tasks service, write durability truth lives in Postgres, downstream reaction truth lives in Kafka consumers, and freshness truth for cached reads lives in invalidation plus observability. Once those boundaries are explicit, the stack stops feeling like 'microservices plus infra' and starts feeling like one system.
- Authenticate once at the edge, but keep domain authorization where the domain data lives.
- Convert transport input into typed commands early so application logic is testable without HTTP fixtures.
- Use a single local transaction for the business write and the durable event intent; do not rely on hope between a commit and a broker call.
- Assume consumers will retry and duplicate; idempotency is part of the contract, not an optimization.
- Count cache invalidation as part of the write path's correctness story once read caches exist.
Fieldwork did not succeed by making every service smart. It succeeded by making each layer responsible for one kind of truth and refusing to let convenience blur those boundaries. That is the decision this capstone locks in.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.