This document records the key architectural decisions for gcode, using ADR (Architecture Decision Record) style: Problem → Constraints → Decision → Consequences.
Problem: Proto files already have an official Go code generation tool protoc-gen-go. Why implement our own?
Constraints:
protoc-gen-gogenerates structs containingprotoimpl.MessageState,sizeCache,unknownFieldsand other runtime fields, which are not suitable as DAO layer data structures- Official generated code depends on
google.golang.org/protobufruntime, introducing unnecessary dependencies - Official generated code's JSON tags don't match project conventions (camelCase vs snake_case)
- Cannot control gorm tags, validate rules, and other business-layer concerns via annotations
Decision: Implement a custom code generator that produces plain Go structs without introducing a protobuf runtime.
Consequences:
- Generated structs are ordinary Go structs, usable directly with GORM, JSON serialization, and HTTP binding
- Wire format compatibility must be self-maintained (regression-tested via
testdata/compat/pbgo/) - Must implement
MarshalBinary/UnmarshalBinaryourselves
Problem: There are multiple ways to parse proto files. Why choose protocompile?
Constraints:
protocis an external binary requiring separate installation, raising the barrier to useprotoccommunicates with generators via a plugin mechanism (stdin/stdout), adding architectural complexity- Need direct access to proto AST and semantic information within the Go process
Decision: Use github.com/bufbuild/protocompile, a pure Go implementation that can be embedded directly in the binary.
Consequences:
- Users only need
go install— no protoc installation required gcode/options.protoandbuf/validate/validate.protoare embedded via//go:embed— no extra files needed- Parse results are accessed directly in-process, no inter-process communication
Problem: Should serialization/deserialization use the official protobuf reflection mechanism or directly manipulate the wire format?
Constraints:
- The official reflection mechanism depends on
google.golang.org/protobuf, conflicting with D1's "no runtime" goal - Must guarantee full compatibility with the official protobuf binary format
Decision: Implement protobuf wire format encoding primitives directly in the runtime/ package (varint, ZigZag, tag, length-delimited). Generated code calls these primitives directly.
Consequences:
- Generated
MarshalBinary/UnmarshalBinaryhas no external runtime dependencies - Wire format compatibility is verified byte-by-byte against
protoc-gen-gooutput intestdata/compat/compat_test.go runtime/is a public package, importable by user projects
Problem: Why introduce two layers of intermediate representation between parser and render?
Constraints:
- Proto semantics (snake_case field names, nested messages, proto type system) differ significantly from Go semantics (CamelCase, flat types, Go type system)
- The render layer should not need to understand proto syntax details
- Naming conflict resolution and type mapping logic need to be centralized
Decision:
model.File: proto semantic model, one-to-one with proto syntax, Go-agnostictransform.GoFile: Go semantic model, with naming conversion, type resolution, and nesting flattened
Consequences:
- The render layer only deals with Go concepts, keeping logic simple
- Naming conflict resolution (
GoCamelCase+ conflict suffix) is centralized in the transform layer - Nested messages are flattened to top-level types; no nesting in generated Go code
Problem: How should proto3 optional fields represent "not set" semantics in Go?
Constraints:
- Proto3 scalar fields default to zero values, making it impossible to distinguish "not set" from "set to zero"
- The
optionalkeyword introduces field presence semantics optional byteshas special semantics: nil means "not set",[]byte{}means "set to empty"
Decision:
optionalscalar/enum fields → generate*T(pointer type)optional bytes→ generate[]byte(nil means not set, no double pointer needed)- Non-optional fields keep their original type
Consequences:
- Users check nil to determine whether a field is set
- nil pointer fields are skipped during marshal (not written to wire)
- nil pointer fields skip validation
Problem: How should create/update derived messages carry validate rules?
Constraints:
- Derived message fields are a subset of source message fields; validate rules should be visible in the generated proto files
- Optional fields (pointer types) in derived messages should not trigger validation when nil
- condition_fields (WHERE clause fields) are required in update scenarios and should not have zero-value guards
- required_fields (forced non-optional in create scenarios) also have no nil guard and are validated directly
- Validate rules should be readable from the proto file itself — no hidden cross-file lookup
Decision:
gen-protocopies validate annotations from the schema (.meta.proto) directly into the generated*.create.proto/*.update.protofields- The render layer reads validate rules directly from the derived message's own fields — no
MessageIndexlookup - Optional fields (pointer types): generate
if p.Field != nil { ... }guard - condition_fields: disable zero-value guard (
name != ""), validate directly update_source/create_sourceannotations are retained for ToEntity/ApplyTo/ToMap/GormMessageOptions — they declare derivation identity, not validate rules
Consequences:
- Validate rules are visible in the generated proto files — users can read
*.create.proto/*.update.prototo understand all constraints - The render layer has no implicit dependency on a source message index for validation
- Derived message
Validate()methods are semantically consistent with the schema definition
Problem: How should derived messages be generated from a proto file?
Constraints:
- Derived messages must exist as independent proto messages to be referenced by service RPCs
- Generated intermediate proto files can be reused by other tools (e.g. protoc-gen-go, buf), maintaining proto ecosystem compatibility
- CLI maintains single responsibility: gen-proto handles proto generation, gcode handles Go generation
- The schema source (
.meta.proto) should be the single source of truth for validate rules
Decision: Two-stage pipeline:
gcode gen-proto: reads.meta.protoschema files, generates three types of proto files per schema:*.entity.proto— struct definition (no validate annotations, with gorm)*.create.proto— create message (validate annotations copied from schema)*.update.proto— update message (validate annotations copied from schema)
gcode: processes entity/create/update/service proto files to generate Go code; skips.meta.protofiles
Consequences:
- Derived messages are real proto messages, directly referenceable by service RPCs
- Two-stage decoupling: gen-proto only cares about proto generation, gcode only cares about Go generation
- Validate rules are explicit in the generated proto files — no hidden cross-file lookup in the render layer
update_source/create_sourceannotations in generated files record derivation identity for ToEntity/ApplyTo/ToMap
Problem: What should the generated service code contain?
Constraints:
- Different projects use different transport protocols (HTTP, gRPC, message queues)
- Route registration, middleware, and authentication are application-layer concerns, outside the scope of code generation
- Streaming RPCs require special handling and are not supported in the current version
Decision: Generate only Go interfaces with fixed method signatures: Method(ctx context.Context, req *XxxRequest) (*XxxResponse, error). No routing, serialization, or client stubs.
Consequences:
- Users have full control over the transport layer (route path, HTTP method, middleware)
- Interfaces can be implemented by any transport protocol (HTTP, gRPC, test mocks)
- Streaming RPCs cause an error exit rather than being silently ignored
Problem: How should HTTP handlers be generated while keeping the transport layer decoupled from the business layer?
Constraints:
- Handlers should not depend on concrete service implementation types
- Generated code cannot know which field corresponds to a path param (proto fields have no positional semantics), so path params are not supported; internal services use JSON body uniformly
Validate()is a public method usable in any context; built-in handler invocation ensures uniform interception at the transport layer
Decision:
- Generate handler factory functions
XxxHandler(svc XxxService, interceptors ...handlerx.Interceptor[*Req, *Resp]) gin.HandlerFuncaccepting an interface, not a concrete type; delegate tohttpruntime.NewHandlerwhich applies the interceptor chain with built-in panic recovery - Use
c.ShouldBindJSONuniformly (enforces JSON body; no path param support) - Handlers call
req.Validate()built-in (after bind, before svc call);Validate()remains a public method for reuse in other contexts - Use
c.Request.Context()to propagate request context, preserving deadline/cancel/trace information
Consequences:
- Handlers are decoupled from service implementations; mocks can be injected in tests
- Route paths and HTTP methods are fully controlled by the user
- Validation runs automatically at the transport layer without preventing standalone calls in service or other contexts
Problem: How should HTTP status codes and business codes be separated?
Constraints:
- HTTP status is transport-layer semantics; business code is application-layer semantics
- Mixing them causes middleware, load balancers, and monitoring systems to misinterpret business errors
Decision: HTTP status always returns 200; business results are conveyed via the code field in the response body:
- Success:
{"code": 0, "data": {...}} - Error:
{"code": 5000, "error": {"msg": "..."}}
Two-layer error code mechanism:
CodedErrorinterface: business errors implementCode() int;ErrResponseextracts it automatically; defaults to CodeDefaultErr (5000) otherwise- gin middleware: can completely replace the error response format for cross-cutting concerns
Consequences:
- Clients determine success/failure via the
codefield, not HTTP status - Business layer customizes error codes by implementing
CodedError, without modifying generated code
Scope limitation: This design targets internal business RPC handlers only. Infrastructure endpoints that must use HTTP status code semantics — health checks (/healthz, /readyz), load balancer probes, and monitoring scrapers — are outside the scope of gcode and should be implemented manually. Those are infrastructure concerns, not business logic.
Problem: How should multiple struct tag types (json, gorm, potentially validate tags, etc.) be supported without hardcoding?
Constraints:
- json tags are built-in and required for all structs; omitempty/ignore logic is tightly coupled to json tag string construction and is not suitable for abstraction as a provider
- gorm tags are optional, generated only for messages configured with
(gcode.message).gorm - Future tag types may be needed (e.g.
mapstructure,yaml)
Decision: Define a TagProvider interface. The render layer accepts []TagProvider and calls each provider in order to generate tag fragments. json tags are built-in logic; gorm tags are implemented via GormTagProvider.
Consequences:
- Adding a new tag type only requires implementing the
TagProviderinterface — no changes to render core logic _defaultProvidersincludesGormTagProvider; users control gorm tag generation via annotations
Problem: How should validate rules be defined? Design a custom annotation syntax or reuse an existing standard?
Constraints:
- A custom annotation syntax requires users to learn a new DSL
- buf/validate is a widely-used proto validation standard with clear semantics
Decision: Directly reuse buf/validate/validate.proto annotation syntax. Embed buf/validate/validate.proto in the binary so users can use (buf.validate.field).* annotations directly in their proto files.
Consequences:
- Users don't need to learn a new annotation syntax
- Validate rules are compatible with the buf/validate ecosystem (though the runtime is self-implemented, not dependent on the buf/validate runtime)
buf/validate/validate.protois injected viaembeddedResolver; no buf toolchain installation required
Problem: Should generated HTTP handlers write error responses directly, or propagate errors through the gin context?
Constraints:
- When handlers write responses directly (
c.JSON), middleware cannot intercept errors, making it impossible to handle ValidationError (CodeValidationErr) and business errors (CodeDefaultErr) uniformly - Users may need to customize error response format (e.g. adding request_id, trace_id, internationalized messages)
- ValidationError should map to CodeValidationErr (1001); other errors should map to CodeDefaultErr (5000) or CodedError.Code()
- When no error-handling middleware is registered, behavior must be clearly documented — errors must not be silently lost
Decision:
- All error paths in handlers use
_ = c.Error(err); return— no direct response writing httpruntime.DefaultErrorHandler()serves as a gin middleware fallback: ValidationError → CodeValidationErr (1001), others → CodeDefaultErr (5000) or CodedError.Code()- The function's doc comment explicitly warns: without this middleware, error paths return HTTP 200 with an empty body
Consequences:
- Users can replace DefaultErrorHandler with a custom implementation without modifying generated code
- ValidationError automatically maps to CodeValidationErr (1001) — no per-handler repetition needed
- Error handling logic is centralized in middleware; handlers remain clean
- The risk of not registering DefaultErrorHandler is clearly communicated via documentation and code comments
Problem: Should create derived structs inherit TableName() from the source struct? What should ToMap() use as map keys?
Constraints:
- A create derived struct is intended to "insert a record into the source struct's table" — semantically it belongs to the same table
- GORM's
db.Create(&PersonCreate{...})requiresTableName()to find the correct table; without it, GORM infers the table name from the struct name (person_creates), causing errors - GORM's
db.Updates(map)uses map keys directly as database column names — it does not walk struct tags — soToMap()keys must be column names - Update derived structs use
db.Model(&Person{})to specify the table explicitly; they do not needTableName() - Create derived structs have
GormMessageOptions == nilat the transform layer (derived protos have no gorm annotation); inheritance must happen at the render layer viaContext.MessageIndex
Decision:
- Both the original struct and create derived structs generate
TableName(); the create derived struct inheritsGormMessageOptionsat render time by looking up the source message inContext.MessageIndex(shallow copy — the caller'sGoFileis not mutated) - Update derived structs do not generate
TableName() ToMap()keys use the(gcode.field).gorm.columnoverride when present; otherwise the proto field name is usedgenprotocopies field-levelgcode.field.gorm.columnannotations to derived protos, ensuring derived struct fields carry the correct column name information
Consequences:
- Users can call
db.Create(&PersonCreate{...})directly without specifyingdb.Model(&Person{}) db.Model(&Person{}).Updates(req.ToMap())correctly matches database column names even when column name overrides are present(gcode.field).gorm.columnconsistently affects both struct tags andToMap()keys- The render-layer inheritance uses shallow copy, producing no side effects — the caller's
GoFileis not modified
Problem: Should gcode generate a full TypeScript SDK (HTTP client, serialization) or just type definitions?
Constraints:
- gcode's primary goal is Go code generation; TypeScript support is supplementary
- Frontend projects use diverse HTTP clients (fetch, axios, tRPC) and validation libraries (zod, yup, ajv)
- Proto annotations define validation rules that should be reusable across frontend libraries
- protobuf binary serialization on the frontend adds complexity with limited benefit for JSON-based APIs
Decision: Generate pure type definitions only:
interfacefor proto messages (camelCase property names matching Go JSON tags)enum+ name mappingRecordfor proto enums- Validation metadata as typed
constobjects (library-agnostic format) - ES Module format with
.jsextension imports for maximum compatibility
Consequences:
- Frontend gets type safety and validation metadata without being locked into a specific library
- No runtime serialization — frontend consumes data via JSON fetch, which is the dominant pattern
- Validation metadata can drive form validation, UI constraints, or be converted to zod/yup schemas
gen-tsis a separate subcommand, cleanly decoupled from Go generation
Problem: Should the import paths for runtime, validateruntime, and httpruntime be configurable?
Constraints:
- Generated code must import the runtime packages to compile
- Making import paths configurable adds CLI flags, configuration complexity, and documentation burden
- The module path
github.com/pinealctx/gcodeis stable; changing it is a major-version-level event
Decision: Hardcode the runtime import paths in the generator. Generated code always imports:
github.com/pinealctx/gcode/runtimegithub.com/pinealctx/gcode/validateruntimegithub.com/pinealctx/gcode/httpruntime
Consequences:
- No configuration needed for the common case
- If the module is forked or renamed, the generated import paths must be updated manually — this is intentional: a module rename is a breaking change and warrants explicit action
- Customizable import paths are deferred to a future major version if the need arises
Problem: Should update_source (field 50005) and create_source (field 50006) annotations be removed from generated *.update.proto / *.create.proto files now that validate rules are copied explicitly?
Constraints:
ToEntity(),ApplyTo(),ToMap(), andGormMessageOptionspropagation all depend on these annotations to identify the source entity and locate it inMessageIndex- Removing them would require an alternative mechanism for the render layer to find the source message
- The original motivation for removal was to eliminate the "implicit validate lookup" — that concern is resolved by D6 (validate rules are now explicit in the proto fields)
Decision: Retain update_source/create_source annotations in generated files. They serve a different purpose from validate: they declare derivation identity (which entity this message derives from), not validation rules. This is explicit, not implicit.
Consequences:
ToEntity(),ApplyTo(),ToMap(), andGormMessageOptionscontinue to work without changes- The render layer has two distinct concerns: derivation identity (via
update_source/create_source) and validate rules (via field annotations) — both are explicit - Future removal of these annotations is possible if a better mechanism for derivation identity is designed, but is deferred until then
Problem: Should Go generation preserve the source proto directory structure in the output directory?
Constraints:
- Go package boundaries are directory-based.
- The current Go render layer emits same-package cross-file references as unqualified type names, such as
Address, notcommon.Address. - The generator does not currently track per-type output directories, import paths, import aliases, or qualified Go type names.
- Preserving proto subdirectories would split one logical
go_packageinto multiple Go package directories and can make generated code fail to compile.
Decision: Go generation writes all generated Go files into the single directory passed via -out. Output filenames are derived from the proto basename. Same-basename proto files are rejected in one generation run with ErrOutputFilenameCollision.
Consequences:
- Same-
go_packagecross-file references compile because all generated Go files live in the same Go package directory. - Users must avoid same-basename proto files in one Go generation run, even if they are in different source subdirectories.
- Supporting preserved Go output directories requires a future design for cross-directory imports and qualified type names; it is intentionally not implemented as a partial fix.