Stage 7 · Master
Identity Service: HTTP API Layer
Request and Response Contracts with Validation
Why HTTP DTOs are not database rows, how strict decoding and layered validation prevent fragile APIs, and how Meridian should use the existing Platform Contract types once identity-service grows real handlers.
Contracts Are Boundaries
An HTTP contract should describe what a client is allowed to send and what it may rely on receiving back. That sounds obvious, yet many beginner backends accidentally equate a database struct, a domain model, and an API shape. The result is brittle coupling: a storage-motivated column appears in JSON, a future migration becomes a breaking API change, or a client is asked to send fields that only the server should ever decide.
Meridian's identity-service is a good place to learn this discipline because the domain contains sensitive distinctions. A client may be allowed to request a membership invitation, but not to choose created_at, tenant_id, or the server's canonical role-normalization rules. DTOs are therefore not ceremony. They are the tool that makes the contract narrower than the database and safer than convenience-driven reuse.
Designing Request DTOs
A good request DTO exposes only caller-controlled fields. For example, creating a membership invitation might accept user_id and role. It should not accept tenant_id in the JSON body if tenant scope already comes from the route or gateway-provided context. It should not accept permission bundles directly if the service's role model is authoritative. Every field that is removed from the public request surface is one fewer place a client can accidentally or maliciously contradict the server's invariants.
Strict Decoding and Validation
Validation should happen in layers. Decoding enforces syntactic truth: valid JSON, one object, known fields. Request validation enforces shape truth: required fields, allowed enums, size limits, and UUID format. Service-level validation enforces workflow truth: can this actor grant that role inside this tenant, does the referenced user exist, and is this operation compatible with current membership state? Each layer answers a different class of question, which is why collapsing them all into one giant if statement produces confusing code and weak error messages.
| Validation layer | Typical responsibility | Example |
|---|---|---|
| JSON decoding | Reject malformed or unknown input shape | Fail if the body contains roleName instead of role |
| DTO validation | Reject invalid field formats or missing required fields | Fail if user_id is empty or role is outside the allowed set |
| Service validation | Reject domain-invalid operations | Fail if actor lacks permission to assign tenant_admin |
Shaping Responses
Responses deserve the same care as requests. A Membership domain model might carry tenant_id internally, but a route already nested under /v1/tenants/{tenantID}/memberships may not need to repeat that value in every JSON payload. A Tenant domain model may have internal bookkeeping fields the client should never see. Response DTOs let the server publish exactly the public surface it intends rather than whatever the storage layer happened to need.
Notice the separation between the success envelope and the payload inside data. The envelope standardizes transport shape across services. The payload type remains free to evolve per endpoint. That is an important API-design pattern: standardize the outer contract once, then keep inner resource schemas intentional and local to the endpoint that owns them.
Current Repository Status
identity-service currently exposes only GET /healthz from cmd/identity-service/main.go. No DTOs, validators, or response.OK(...) calls are implemented in meridian today. This chapter specifies the HTTP contract design for the first real identity handlers.
apps/identity-service/internal/http/handlers.gocreateResponsibility: Defines request DTOs, strict decoding helpers, and response writers for identity endpoints.
Why now: The first real handlers need a home that separates HTTP contracts from domain entities.
apps/identity-service/internal/domain/domain.gomodifyResponsibility: Receives service inputs produced from request DTOs rather than raw JSON shapes.
Why now: The domain should not know how HTTP happened to name fields.
libs/platform/response/response.gomodifyResponsibility: May evolve if richer pagination metadata becomes necessary later.
Why now: The shared envelope is the cross-service contract boundary, so any changes should happen centrally and deliberately.
- Keep request DTOs narrower than domain models and much narrower than database rows.
- Use strict decoding so unknown fields fail loudly.
- Validate in layers: JSON shape, DTO rules, then domain rules.
- Return the shared response.Envelope on success while keeping payload schemas explicit per endpoint.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.