Skip to content

Codegen Output ​

What craftgo gen writes to disk, file by file. Output is deterministic - the same DSL always produces byte-identical files - and every Go file is run through go/format, so generated code is always gofmt-clean. Architecture is the one-page overview of how these files relate.

Two kinds of file ​

KindHeaderBehavior
Regenerated// Code generated by craftgo. DO NOT EDIT. (# Generated by craftgo. DO NOT EDIT. in openapi.yaml; the pb code carries the plugins' own)Overwritten on every craftgo gen. Never edit these.
Gen-once// Scaffold generated by craftgo. Safe to edit; will not be overwritten. (none in config.yaml and example.config.yaml)Written only if absent. Your edits survive regeneration.

The split is the core of the workflow: regenerated files are the mechanical translation of your DSL; gen-once files are where you live. The CLI checks for existence before writing a gen-once file, so it never clobbers your business logic.

A run renders every file before it writes any. Each one is then written to a temporary file beside it and renamed into place, keeping the mode of the file it replaces, and the sweep runs last. So a run that fails - a protoc plugin that cannot run, a file it cannot write - names the cause and leaves the tree as it was, and a run cut short never leaves a half-written file behind.

Directory layout ​

For a project whose design declares package design with a UserService:

internal/
├── types/<pkg>/                 REGEN - one folder per DSL package
│   ├── types.go                 structs for every type
│   ├── validate.go              Validate() method per type
│   ├── enums.go                 enum types and const values
│   └── errors.go                typed error values
├── transport/<svc>/             REGEN - one folder per service
│   ├── <method>.go              http.HandlerFunc per method
│   └── bodies.gen.go            wire structs a JSON body with a required member decodes into
├── service/<svc>/               GEN-ONCE - your business logic
│   └── <method>.go              the stub you fill in (HTTP methods and RPCs alike)
├── pb/<dir>/                    PLUGIN - protoc-gen-go + protoc-gen-go-grpc, per .proto directory
│   ├── <file>.pb.go             messages
│   └── <file>_grpc.pb.go        client, server interface, Unimplemented server
├── grpc/<svc>/                  REGEN - one folder per proto service
│   ├── server.go                Server implementing pb.<Svc>Server
│   └── <rpc>.go                 one method per RPC, delegating to service/
├── events/<pkg>/                REGEN - one folder per DSL package declaring an event
│   └── events.go                contract constant + descriptor per event
├── routes/
│   ├── routes.go                REGEN - umbrella registration
│   └── <svc>/routes.go          REGEN - per-service RegisterRoutes
├── middleware/
│   └── <name>_middleware.go     GEN-ONCE - one per declared middleware
└── wiring/
    ├── wiring.go                REGEN - the single Register call main.go makes
    └── grpc.go                  REGEN - RegisterGRPC, present while a proto declares a service

svccontext/
├── svccontext.go                GEN-ONCE - your dependency container
└── middlewares.go               REGEN - typed middleware fields

config/                          GEN-ONCE - runtime config loader
├── config.go
├── config.yaml
└── example.config.yaml

docs/openapi.yaml                REGEN - OpenAPI 3.1 spec
main.go                          GEN-ONCE - wired entry point, when the design declares an HTTP route or a proto service

enums.go and errors.go are written only for a package that declares an enum or an error.

Here <method> / <svc> / <name> render in the case set by output.fileCase (snake_case by default, so create_user.go and user_service/; kebab and camel are the alternatives). Output directories and this naming are configurable in craftgo.design.yaml - see Configuration.

What each file contains ​

types/<pkg>/types.go (regen) ​

A Go struct per type, with field names mapped to Go conventions (user_id → UserID), JSON tags from the DSL field names, pointers for optional (?) fields, and Go-generic syntax for generics (Page<User> → Page[User]). Cross-package field types resolve to the right import.

types/<pkg>/validate.go (regen) ​

A Validate() error method per type. Every validator decorator becomes a plain if statement, with no runtime struct-tag parsing. Reflection enters only through the generated validateValue, which a generic type's Validate calls for a type-parameter value whose type has no Validate of its own; it walks that value's elements with reflect. Regexes (@pattern, regex-backed @format) compile once into package-level vars. Validate() is fail-fast - it returns the first violation. Nested struct fields, generic instances, map values/keys, and embedded mixins all dispatch recursively. Each dispatch wraps the inner error as <field>: %w, so the message names the path (home: rooms: furniture: name: length less than 1) and errors.Is / errors.As still reach the inner error; a mixin's fields keep their bare names.

types/<pkg>/enums.go + errors.go (regen) ​

Enum types and their constants (TodoStatusOpen, …) — membership validation lives in validate.go, not here; typed error values with their HTTP category, code, and any custom body fields.

transport/<svc>/<method>.go (regen) ​

One func <Method>(svcCtx) http.HandlerFunc per method. The handler:

  1. Pre-fills the @default of each @query, @header, @cookie and @form field (before binding, so a value the request carries overrides the default),
  2. Decodes the request (JSON body, or binds @path / @query / @header / @cookie / @form fields with the right casts). A JSON body with a required member decodes first into its wire struct, one per type it reaches, in bodies.gen.go beside the handlers: a pointer per member, so a member left out or sent as null answers 400 <json path>: required; the struct's to copies it into the request and fills the @default of each member left out or sent as null, at any depth,
  3. Calls req.Validate(),
  4. Dispatches to your service logic,
  5. Encodes the response as the logic returns it (and writes any @status, response headers, or cookies). Nothing fills a nil list or map: it goes out as null, so logic returns [] or {} for a required one that is empty.

Raw sides change who runs which steps. A request / response block on a raw side still drives the OpenAPI document and the generated types; the handler just never touches it:

ModeSteps the handler runsStub signature
@rawResponse1–4, then hands w to logic(w http.ResponseWriter, r *http.Request, req *types.Req) error
@rawRequest4–5 only, hands r to logic unread(r *http.Request) (*types.Resp, error)
@passthrough4 only(w http.ResponseWriter, r *http.Request) error

service/<svc>/<method>.go (gen-once) ​

The stub you implement:

go
// GetUserService runs UserService.GetUser for one request.
type GetUserService struct {
	log.Logger
	ctx    context.Context
	svcCtx *svccontext.ServiceContext
}

// NewGetUserService binds GetUserService to ctx; its Logger carries ctx's trace ids.
func NewGetUserService(ctx context.Context, svcCtx *svccontext.ServiceContext) *GetUserService {
	return &GetUserService{
		Logger: log.Default().WithContext(ctx),
		ctx:    ctx,
		svcCtx: svcCtx,
	}
}

// GetUser implements UserService.GetUser.
func (l *GetUserService) GetUser(req *types.GetUserReq) (*types.User, error) {
	// TODO: implement
	return nil, server.ErrNotImplemented
}

Until you write it, the stub returns server.ErrNotImplemented, which server.WriteError answers 501 {"message":"not implemented"}. A stub for an RPC returns the gRPC twin, status.Error(codes.Unimplemented, "not implemented").

A method's @doc comes first in its comment, then an empty // line (// Fetch one user. / // / // GetUser implements UserService.GetUser.). The stubs, the middleware scaffolds, svccontext.go, config/ and main.go are where you write code; everything else is regenerated.

An RPC gets the same stub from the same template, with the pb types in the signature and the generic stream types for the streaming shapes - see gRPC.

pb/<dir>/ (plugin) ​

The code protoc-gen-go and protoc-gen-go-grpc write for every .proto under the design folder, run by craftgo gen through go tool. The directory mirrors the proto's; the headers are the plugins' own, naming craftgo as the compiler (protoc v0.0.0-craftgo). The sweep deletes a file here only where the plugins write the code of the proto its header names as the source, when no proto.includes root holds that proto - and, in a directory no design proto writes into any more, only a file that header marks as craftgo's. A design with no proto sweeps nothing here. output.pb: "-" runs no plugin.

grpc/<svc>/ (regen) ​

server.go declares Server, embedding pb.Unimplemented<Svc>Server, and NewServer(svcCtx); each <rpc>.go implements one method of the generated interface: rpc.Validate on the request (a no-op until a validating plugin such as protoc-gen-validate gives the message a Validate() error; a client-streaming or bidi RPC passes the stream on through rpc.ValidateClientStream or rpc.ValidateBidiStream, whose Recv runs the same check on each message), service.New<Rpc>Service(ctx, svcCtx), the call, rpc.Error on failure.

routes/ (regen) ​

routes/<svc>/routes.go registers each method on the mux via srv.Handle("VERB /path", transport.X(svcCtx), mws...), applying declared middleware. routes/routes.go is the umbrella that calls every per-service RegisterRoutes.

wiring/wiring.go (regen) ​

The one call main.go makes to attach the design:

go
func Register(ctx context.Context, srv *server.Server, svcCtx *svccontext.ServiceContext) (func(context.Context) error, error)

It registers every service's routes and fails at startup for every middleware the design applies that svcCtx leaves nil, naming the line that would assign it - a nil middleware is skipped by the chain rather than called, so without the check the guarantee the design makes is silently missing. The returned shutdown runs beside srv.Stop. The body changes with the design; the signature does not, which is what lets main.go be written once and never edited again. It is emitted even for a design with no route.

wiring/grpc.go (regen) ​

The gRPC twin, written while a proto declares a service and swept otherwise:

go
func RegisterGRPC(ctx context.Context, srv *rpc.Server, svcCtx *svccontext.ServiceContext) (func(context.Context) error, error)

It registers every proto service's Server on srv.

svccontext/ (gen-once + regen) ​

svccontext.go is your dependency container - add DB handles, clients, config here. middlewares.go (regen) declares the typed middleware fields so @middlewares(Auth) has a svcCtx.Auth to resolve against.

docs/openapi.yaml (regen) ​

The OpenAPI 3.1 document - paths, component schemas, parameters, request bodies, responses, security schemes. See OpenAPI.

main.go (gen-once) ​

Wires the ServiceContext, the server.Server, route registration, middleware, logging/metrics/otel, and the listener. With a proto service it also builds the rpc.Server, its interceptors and wiring.RegisterGRPC; a design of protos alone boots the gRPC listener only. Each listener binds before it logs the address it holds, the real port of a :0 included; one that cannot bind, or stops serving, shuts the rest down as a signal does, and the process exits 1. On SIGINT or SIGTERM every listener stops at once and drains within one 5s budget, a drain the budget cuts short is logged, and the telemetry flush comes last. Yours to customize - add a flag, change the listen address, register an extra middleware.

events/<pkg>/events.go (regen) ​

Written per DSL package, not per service, and only when that package declares at least one event. Per event: a <Name>Contract constant holding the wire identity (<package>.<Event> unless @contract("...") names another), and one events.Event[T] descriptor bound to the payload type and its Validate():

go
// PlacedContract is the wire identity of Placed.
const PlacedContract = "orders.Placed"

// An order was accepted.
//
// Placed is the orders.Placed event contract.
var Placed = craftevents.NewEvent[types.OrderPlaced](PlacedContract, (*types.OrderPlaced).Validate)

A payload T[] contract is typed on the slice - craftevents.NewEvent[[]types.OrderPlaced](BatchPlacedContract, validateBatchPlaced) - and the file declares validateBatchPlaced, which runs each element's own Validate and names the index that failed. That validator is the only reason the file ever imports fmt.

The file imports the event runtime, the payload types, the packages of a generic payload's type arguments (time for payload Envelope<datetime>) and (for an array payload) fmt, and nothing else, which is what keeps it importable on its own. @group nests the HTTP handlers and service stubs; the event library is placed per DSL package and is unaffected by it. A package that declares no event leaves no folder behind.

See Events for the rest.

Drift safety ​

Because regenerated files are deterministic and headed with the // Code generated marker, you can commit them and let CI re-run craftgo gen + git diff --exit-code as a drift gate: if the committed output doesn't match what the current DSL produces, the build fails. That guarantees the checked-in code always matches the design.

Released under the MIT License.