Stage 7 · Master
Building the HTTP API with Gin
Request/Response Contracts & Validation
Fieldwork keeps HTTP DTOs separate from database models and rejects bad input before repository code ever sees it.
Why Row Shapes Should Not Be API Shapes
One of the fastest ways to make an API brittle is to let database structs leak straight through the transport layer. It feels efficient at first: scan rows into a struct, marshal it to JSON, done. The problem is that storage shape and API contract age for different reasons. The database carries columns for joins, indexing, tenancy, and operational bookkeeping. The API should carry the fields a client actually needs, named the way the public contract intends. Those are not the same responsibility, so Fieldwork does not pretend they are.
I rejected the 'one struct per entity everywhere' pattern because it creates accidental coupling between migrations and HTTP responses. Add a column for internal bookkeeping and it suddenly appears in JSON unless someone remembers to hide it. Rename a field for SQL clarity and clients feel it. In Fieldwork, request DTOs, domain inputs, and response DTOs are separate on purpose. That introduces a bit of mapping code and removes a lot of hidden blast radius.
The goal is not to satisfy an architectural pattern. The goal is to stop storage concerns like tenant_id, archived_at, or internal role bookkeeping from accidentally becoming API design decisions.
Defining Request Contracts on Purpose
Fieldwork request types are designed around the action being performed, not the whole table row. Creating a task does not let the client set tenant_id, workspace_id, created_at, or completed_at. Those values come from route scope, auth context, or server-side workflow rules. The request contract accepts only what the caller is allowed to decide: title, description, assignee, priority, and due date. Narrow inputs are easier to validate and harder to abuse.
That ToInput mapping is intentionally boring. It is also where you can normalize whitespace, trim empty strings, or collapse transport-specific oddities before domain logic begins. Validation does not need to do everything, but it should ensure the service receives structurally sane input instead of forcing repository code to guard against obviously malformed payloads.
Validation Before the Repository
Fieldwork validates in layers. Gin binding validates shape-level concerns such as required fields, max lengths, and enum membership. Service-level validation handles rules that depend on workflow context: maybe due_at cannot be in the past for newly created tasks, or only workspace admins may assign urgent priority in a certain rollout. Repository code is not the place for that. By the time data reaches SQL, the remaining failures should be persistence failures or race conditions, not basic request contract mistakes.
| Validation kind | Where it happens | Example |
|---|---|---|
| Shape validation | Gin binding / DTO tags | title required, priority in allowed values |
| Cross-field validation | Handler or service | dueAt must be after now for open tasks |
| Authorization validation | Middleware or service | caller must belong to workspace |
| Persistence validation | Repository / DB | unique slug constraint or FK violation |
A payload can be perfectly well-formed and still be forbidden. Do not collapse 'invalid' and 'not allowed' into one vague validation layer just because both happen before a database write.
Response Models That Don't Leak Storage Details
Responses follow the same rule as requests: shape them for clients, not for tables. A task response might include assignee info, project metadata, and friendly timestamps, while excluding tenant_id because it is already implied by the route. Internal fields like created_by_user_id may stay out entirely unless the product actually needs them. That freedom disappears if the response type is just the repository row struct with json tags added as an afterthought.
This separation also helps versioning later. If a v2 endpoint wants to add assignee details or change how status metadata is represented, it can do so without touching the storage model. That is the kind of flexibility that feels unnecessary in week one and indispensable once clients exist.
The Decision
- Do not expose repository or database structs directly over HTTP.
- Keep request DTOs narrow so clients can only set fields they truly own.
- Validate shape at the handler boundary and workflow rules in the service layer.
- Map domain models into explicit response DTOs with stable public field names.
Fieldwork separates request DTOs, domain inputs, repository models, and response DTOs because the system's boundaries have different jobs. The rejected shortcut was reusing one struct everywhere. That saves a little typing and creates a lot of accidental coupling. Here, validation happens before repository code, and response contracts stay deliberate instead of inheriting whatever the database happened to need.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.