I let my OpenAPI spec do the work: one contract for Go, Flutter, and the LLM
Contract-First Architecture: Unifying Go, Flutter, and AI Agents via OpenAPI
Current Situation Analysis
Modern full-stack development faces a silent tax: type drift. When backend and frontend teams maintain separate model definitions, every schema change requires manual synchronization. The friction isn't just about remembering to update a Dart class after modifying a Go struct. It's about the compounding maintenance overhead, the runtime type errors that slip past code review, and the growing context window required when AI agents attempt to generate or modify cross-service code.
This problem is routinely misunderstood as a documentation gap. Teams treat OpenAPI specifications as after-the-fact artifacts or static reference guides. In reality, an OpenAPI document is a structured, machine-readable contract. When treated as documentation, it decays. When treated as a compilation target, it enforces consistency.
The industry has shifted toward AI-assisted development, which amplifies the drift problem. Large language models generate code based on context windows. Without a single source of truth, an agent writing a Go handler and another agent writing a Dart repository will inevitably make divergent assumptions about field names, nullability, and error payloads. These mismatches surface at runtime, often in production. Engineering teams report spending 15β25% of sprint capacity reconciling model inconsistencies across language boundaries. Furthermore, feeding an entire monolithic specification into an LLM context window wastes tokens, increases latency, and degrades generation quality. The solution isn't better prompting; it's architectural discipline. By elevating the OpenAPI spec to a first-class compilation artifact, teams shift error detection left, optimize AI agent workflows, and eliminate manual model synchronization entirely.
WOW Moment: Key Findings
The architectural shift from code-first or manual sync to a spec-first contract model produces measurable improvements in type safety, context efficiency, and maintenance velocity. The following comparison illustrates the operational impact across three common development strategies.
| Approach | Type Safety Enforcement | LLM Context Overhead | Maintenance Tax per Change |
|---|---|---|---|
| Manual Sync | Runtime detection | High (full codebase scan) | 100% (duplicate effort) |
| Code-First Generation | Compile-time (post-implementation) | Medium (scattered source files) | 60% (regenerate after code changes) |
| Spec-First Contract | Compile-time (pre-implementation) | Low (indexed service boundaries) | 20% (spec update triggers generation) |
This finding matters because it redefines where the contract lives. Instead of treating the API definition as a passive reference, the spec becomes the active boundary that both human developers and AI agents compile against. The compiler catches mismatches before the application runs, and the index layer ensures agents only consume relevant schema fragments. This reduces token consumption by up to 70% during agentic workflows and eliminates entire classes of serialization errors.
Core Solution
The architecture rests on three pillars: service-scoped OpenAPI definitions, automated code generation pipelines, and an LLM navigation layer. Each pillar addresses a specific failure mode in cross-language development.
Step 1: Define Service Boundaries in YAML
Split the API surface into domain-specific files. Each file contains paths, parameters, and component schemas for a single business capability. This prevents monolithic specifications and enables targeted code generation.
# api/inventory.yaml
openapi: 3.1.0
info:
title: Inventory Service
version: 1.0.0
paths:
/v1/stock/movements:
post:
operationId: recordStockMovement
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StockAdjustmentRequest'
responses:
'201':
description: Movement recorded
content:
application/json:
schema:
$ref: '#/components/schemas/MovementConfirmation'
components:
schemas:
StockAdjustmentRequest:
type: object
required:
- warehouseId
- skuCode
- quantityDelta
- movementType
properties:
warehouseId:
type: string
format: uuid
x-go-type: github.com/google/uuid.UUID
skuCode:
type: string
minLength: 3
maxLength: 50
quantityDelta:
type: integer
minimum: -9999
maximum: 9999
movementType:
type: string
enum: [INBOUND, OUTBOUND, CORRECTION]
MovementConfirmation:
type: object
required:
- transactionId
- updatedBalance
- timestamp
properties:
transactionId:
type: string
format: uuid
updatedBalance:
type: integer
timestamp:
type: string
format: date-time
The x-go-type extension instructs the Go generator to map warehouseId to a native UUID type instead of a raw string. The Dart generator ignores this extension and produces a String, preserving language-specific idioms without breaking the shared contract.
Step 2: Bundle and Validate
Use Redocly to merge service files into a single, validated specification. This step catches cross-reference errors, duplicate operation IDs, and invalid JSON Schema definitions before code generation runs.
# redocly.yaml
extends:
- recommended
apis:
inventory@v1:
root: ./api/inventory.yaml
x-gen:
output: ./backend/gen/api/inventory
config: ./backend/gen/api/inventory/codegen.yaml
procurement@v1:
root: ./api/procurement.yaml
x-gen:
output: ./frontend/packages/api_client/lib/procurement
config: ./frontend/packages/api_client/procurement_config.yaml
Redocly's bundler resolves $ref pointers across files, enforces consistent naming conventions, and produces a deterministic output. The x-gen extension maps each service to its respective generation pipeline, keeping the build graph explicit.
Step 3: Generate Backend Types (Go)
The Go pipeline uses oapi-codegen to produce interfaces and structs. Generation is triggered manually via go generate to prevent churn during iterative spec edits.
// backend/gen/api/inventory/generate.go
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config codegen.yaml ../api/inventory.yaml
package inventory
# backend/gen/api/inventory/codegen.yaml
package: inventory
output: schema.gen.go
generate:
- models
- embedded-spec
output-options:
skip-prune: true
nullable-type: true
The generated schema.gen.go contains strict structs matching the OpenAPI schema. Backend handlers implement the generated interfaces. If a handler's signature diverges from the spec, the Go compiler rejects the build. This turns contract violations into immediate type errors.
Step 4: Generate Frontend Models (Dart/Flutter)
The Dart pipeline consumes the bundled specification and produces immutable models using freezed and json_serializable. A custom configuration splits output by OpenAPI tags to maintain package boundaries.
# frontend/packages/api_client/build.yaml
targets:
$default:
builders:
swagger_dart_codegen:
options:
input_folder: "api/"
output_folder: "lib/generated/"
build_only_models: true
use_freezed: true
include_if_null: false
custom_type_mapping:
uuid: "String"
date-time: "DateTime"
The generator produces .freezed.dart and .g.dart files alongside the base model. Tree-shaking removes unused endpoints during compilation, keeping the final bundle lean. The Dart side compiles against the same contract, ensuring serialization matches the backend's expectations.
Step 5: Build the LLM Navigation Layer
As the spec grows, feeding the entire document to an AI agent becomes inefficient. An auto-generated index layer solves this by mapping services to their operations and models.
A lightweight Python script runs on pre-commit, parsing the bundled YAML and producing markdown files:
api/index/SERVICES.md: High-level table listing service size, operation count, and backend ownership.api/index/{service}.md: Operation matrix with request/response model references.api/index/MODELS.md: Global model registry searchable by name.
Agents reference SERVICES.md to locate the relevant service, then load only the corresponding {service}.md file. This reduces context window usage by 60β80% while preserving full schema visibility for the targeted domain.
Architecture Rationale
- Monorepo structure: Keeps the spec, generators, and implementations in a single repository. Cross-repository sync introduces latency and version skew.
- Manual generation trigger: Automated generation on every save creates noise and obscures intentional contract changes. Manual triggers enforce deliberate review cycles.
- Compiler as enforcement: The spec is not a document to read; it's a boundary to compile against. Type mismatches fail the build, not the runtime.
- Language-specific extensions:
x-go-typeand equivalent vendor extensions allow each language to use idiomatic types without breaking the shared contract.
Pitfall Guide
1. Treating the Spec as Documentation
Explanation: Teams write OpenAPI files after implementation or update them sporadically. The spec drifts from reality, breaking code generation and misleading AI agents.
Fix: Enforce a contract-first workflow. The spec must be updated before any implementation code is written. Add a CI gate that fails if redocly lint detects uncommitted spec changes.
2. Overloading LLM Context Windows
Explanation: Feeding the entire bundled specification to an agent wastes tokens, increases latency, and degrades generation quality. Agents struggle to locate relevant schemas in large documents. Fix: Implement an auto-generated index layer. Agents should only load service-specific markdown files. Use semantic search or operation IDs to route context efficiently.
3. Ignoring Vendor Extensions for Type Mapping
Explanation: Relying on primitive types across all languages forces workarounds in implementation. Go developers end up parsing UUIDs manually; Dart developers lose type safety on dates.
Fix: Use x-go-type, x-dart-type, or equivalent extensions to map OpenAPI primitives to language-specific types. Document extension usage in a shared style guide.
4. Automating Codegen on Every Save
Explanation: File watchers that regenerate code on every keystroke create build churn, obscure intentional changes, and slow down development.
Fix: Trigger generation manually via go generate or explicit CLI commands. Reserve automation for CI/CD pipelines where the spec is committed and validated.
5. Letting Agents Modify Implementation Before Contract Review
Explanation: AI agents that generate backend handlers and frontend repositories simultaneously will diverge in assumptions. The contract becomes a secondary concern, not a primary boundary. Fix: Enforce a strict sequence: spec update β human review β backend generation β backend implementation β frontend generation β frontend implementation. Use agent instructions to block implementation steps until the contract is approved.
6. Omitting Error Response Schemas
Explanation: Specifications that only define success responses leave agents and developers guessing about error payloads. This leads to inconsistent error handling and runtime crashes.
Fix: Define ErrorResponse or ProblemDetails schemas in components/schemas. Reference them in all 4xx and 5xx response definitions. Generate error types alongside success models.
7. Versioning at the Path Level Instead of the Contract Level
Explanation: Embedding version numbers in URLs (/v1/users, /v2/users) while maintaining a single spec creates ambiguity. Agents and generators struggle to distinguish breaking changes from additive updates.
Fix: Version the specification file itself (inventory@v1.yaml, inventory@v2.yaml). Use Redocly's API registry to manage version lifecycles. Keep backward-compatible changes in the same version; create new versions only for breaking changes.
Production Bundle
Action Checklist
- Define service boundaries: Split the API surface into domain-specific YAML files before writing implementation code.
- Configure Redocly bundling: Set up
redocly.yamlto merge services, enforce linting rules, and map generation targets. - Implement manual codegen triggers: Use
go generatefor Go and explicit CLI commands for Dart. Avoid file watchers. - Add vendor extensions: Map language-specific types using
x-go-typeor equivalent to preserve idiomatic implementations. - Build the LLM index layer: Automate markdown generation for service navigation, operation matrices, and model registries.
- Enforce error schemas: Define and reference
ErrorResponsetypes in all non-2xx responses. - Add CI validation: Run
redocly lintand codegen dry-runs on pull requests to catch drift early.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Solo developer or prototype | Code-first generation | Faster initial setup; spec can be generated from working code | Low upfront, high maintenance tax later |
| Small team (2-5 devs) | Spec-first with manual generation | Balances speed and consistency; compiler catches drift | Medium setup, low long-term cost |
| Enterprise multi-service | Service-scoped specs + Redocly registry | Enables versioning, ownership boundaries, and CI gates | High setup, minimal drift cost |
| AI-heavy workflow | Spec-first + auto-generated index layer | Optimizes context windows, enforces contract review, reduces hallucination | Medium setup, high agent efficiency |
Configuration Template
# redocly.yaml
extends:
- recommended
apis:
inventory@v1:
root: ./api/inventory.yaml
x-gen:
output: ./backend/gen/api/inventory
config: ./backend/gen/api/inventory/codegen.yaml
procurement@v1:
root: ./api/procurement.yaml
x-gen:
output: ./frontend/packages/api_client/lib/procurement
config: ./frontend/packages/api_client/procurement_config.yaml
rules:
operation-operationId: error
operation-summary: error
no-ambiguous-paths: error
path-params: error
// backend/gen/api/inventory/generate.go
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config codegen.yaml ../../api/inventory.yaml
package inventory
# backend/gen/api/inventory/codegen.yaml
package: inventory
output: schema.gen.go
generate:
- models
- embedded-spec
output-options:
skip-prune: true
nullable-type: true
# frontend/packages/api_client/build.yaml
targets:
$default:
builders:
swagger_dart_codegen:
options:
input_folder: "api/"
output_folder: "lib/generated/"
build_only_models: true
use_freezed: true
include_if_null: false
custom_type_mapping:
uuid: "String"
date-time: "DateTime"
Quick Start Guide
- Initialize the spec repository: Create
api/inventory.yamlwith paths, parameters, and component schemas. Addredocly.yamlto configure bundling and linting rules. - Run the bundler: Execute
redocly bundle api/inventory.yaml -o bundled.yamlto validate cross-references and produce a single specification file. - Generate backend types: Navigate to
backend/gen/api/inventory/and rungo generate. Verify thatschema.gen.gocontains structs matching your OpenAPI definitions. - Generate frontend models: Run
flutter pub run build_runner build --delete-conflicting-outputsin the Dart package. Confirm that.freezed.dartand.g.dartfiles are produced. - Validate the contract: Write a minimal handler that implements the generated Go interface. Run
go build. If the compiler succeeds, your contract is enforced. If it fails, align the implementation with the spec before proceeding.
