This document explains how a-kit is structured internally, how each command works, and how the key subsystems (parser, generator, templating) fit together.
main.go
└── cmd.Execute() ← prints banner, delegates to Cobra
├── a-kit create ← scaffold a brand-new project
├── a-kit generate ← (re)generate module code from .proto
├── a-kit version ← print version
└── a-kit help ← built-in by Cobra
The tool has two phases:
| Phase | Command | Entry point |
|---|---|---|
| Scaffold | a-kit create |
internal/scaffold/ |
| Generate | a-kit generate |
internal/proto/ |
// main.go
package main
import "github.com/jacksonfernando/a-kit/cmd"
func main() {
cmd.Execute()
}cmd.Execute() prints the ASCII banner with the current version, then hands control to Cobra:
// cmd/root.go
func Execute() {
fmt.Printf(bannerFmt, version.Get()) // print banner
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}The version string comes from internal/version/version.go. It is injected at build time via -ldflags and falls back to debug.ReadBuildInfo() for go install installs:
// internal/version/version.go
var Version = "dev" // overridden by: -ldflags "-X .../version.Version=v1.2.3"
func Get() string {
if Version != "dev" {
return Version
}
// fallback: read version embedded by go install
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
return Version
}File: cmd/create.go → internal/scaffold/scaffold.go
- Validates the project directory doesn't already exist.
- Renders all project skeleton files from embedded templates.
- Creates empty migration directories with
.gitkeepfiles. - Parses the bundled
api/example.protoand runs the full generator on it.
cmd/create.go
└── scaffold.Generate(opts)
├── render project files from embed.FS templates
│ (main.go, go.mod, Makefile, Dockerfile, config.yaml, …)
├── create migration/ directories
└── generateExampleModule()
└── proto.ParseProto(example.proto)
└── proto.GenerateModule(pf, "example", ...)
All scaffold templates live in internal/scaffold/templates/ and are embedded using Go's embed package:
// internal/scaffold/templates.go
//go:embed templates
var scaffoldTmplFS embed.FS
func mustScaffoldTmpl(name string) string {
b, _ := scaffoldTmplFS.ReadFile("templates/" + name)
return string(b)
}projectFiles() returns a map of output path → template string. Each template receives a templateData struct:
type templateData struct {
ProjectName string // "my-service"
ModuleName string // "github.com/myorg/my-service"
PackageName string // "myservice" (lowercase, no hyphens)
}Templates use Go's text/template syntax, e.g. {{.ModuleName}}, {{.PackageName}}.
After writing all files, the example module is generated by calling directly into the proto generator — the same code path as a-kit generate:
func generateExampleModule(projectDir, modulePath string) error {
content, _ := os.ReadFile(filepath.Join(projectDir, "api", "example.proto"))
pf, _ := proto.ParseProto(string(content))
return proto.GenerateModule(pf, "example", modulePath, projectDir)
}File: cmd/generate.go → internal/proto/
Reads .proto files from api/, parses them, and writes Go source files for each module.
cmd/generate.go
├── resolve which .proto files to process
│ (all api/*.proto, or a specific one if a module name is given)
├── proto.ParseProto(content) → *ProtoFile
└── proto.GenerateModule(pf, ...) → writes Go files to disk
If a specific module name is given (a-kit generate order), only api/order.proto is processed. Otherwise every api/*.proto file is regenerated:
if len(args) == 1 {
protoFiles = []string{filepath.Join(apiDir, args[0]+".proto")}
}
if len(protoFiles) == 0 {
entries, _ := os.ReadDir(apiDir)
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".proto") {
protoFiles = append(protoFiles, filepath.Join(apiDir, e.Name()))
}
}
}The parser does not use protoc. It reads the .proto text line by line with a stack of frames that track the current nesting context.
type frame struct {
kind string // "service" | "message" | "rpc_options" | "http_option"
svcIdx int // index into pf.Services
msgIdx int // index into pf.Messages
rpcIdx int // index into svc.RPCs
pushDepth int // brace depth when this frame was opened
}Depth is tracked by counting { and } characters per line. Frames are popped when depth <= frame.pushDepth.
1. Count { opens and } closes on the line
2. Capture top-of-stack frame (tf) BEFORE any changes
3. Process content at the CURRENT depth using a switch statement
4. Update depth: depth += opens − closes
5. Pop any frames now closed (depth ≤ frame.pushDepth)
Content is processed before the depth changes. This means when we see:
service ExampleService { // depth 0 → opens frame, processed at depth 0
rpc ListExamples(...) { // depth 1 → processed at depth 1 (inside service)
option (...) = { // depth 2 → processed at depth 2 (inside rpc_options)
get: "/v1/..." // depth 3 → processed at depth 3 (inside http_option)
}
}
}switch {
// depth 0: top-level declarations
case depth == 0:
// matches: package, service, message
// depth 1, inside a service block
case depth == 1 && tf.kind == "service":
// matches: rpc declarations (with optional inline annotation)
// depth 2, inside an rpc { ... } block
case depth == 2 && tf.kind == "rpc_options":
// matches: option (google.api.http) = { ... }
// also handles single-line form: { get: "/v1/..." }
// depth 3, inside the google.api.http block
case depth == 3 && tf.kind == "http_option":
// matches: get/post/put/patch/delete: "path", body: "*"
// depth 1, inside a message block
case depth == 1 && tf.kind == "message":
// matches: field declarations (repeated, optional, scalar, message types)
}A common pattern is writing delete/get in a single line:
option (google.api.http) = { delete: "/v1/{name=examples/*}" };When the parser pushes the http_option frame at depth 2, it also immediately scans the same line for the method and path — because after counting opens/closes the net depth is 0 and the frame will be popped before the next line:
case depth == 2 && tf.kind == "rpc_options":
if reHTTPOption.MatchString(line) {
stack = append(stack, frame{kind: "http_option", ...})
// also scan the same line for inline content
rpc := &pf.Services[tf.svcIdx].RPCs[tf.rpcIdx]
if m := reHTTPMethodOpt.FindStringSubmatch(line); m != nil {
rpc.HTTPMethod = strings.ToUpper(m[1])
rpc.HTTPPath = m[2]
}
}*ProtoFile (raw parsed output)
│
▼
buildMessages() → []MsgInfo (proto messages → Go struct metadata)
buildGeneratorDataPair() → GeneratorData (one for external RPCs, one for internal)
│
▼
writeFile(tmpl, data) → renders template → writes .go file
Every template receives a GeneratorData value:
type GeneratorData struct {
ModuleName string // "order"
ModulePath string // "github.com/myorg/my-service"
ServiceName string // "OrderService"
PackageName string // "order"
DirPrefix string // "" (external) or "internal/" (internal)
ModuleImportPath string // full import path for this module's interfaces
RPCs []RPCInfo // all RPC methods in this set
Messages []MsgInfo // all proto messages
NeedsEmpty bool // true if any RPC response is google.protobuf.Empty
HasBodyRPCs bool // true if any RPC needs a request body
}The generator splits RPCs into two groups based on the Internal keyword:
target := &extRPCs
if r.Internal {
target = &intRPCs
}
*target = append(*target, info)- External RPCs →
<module>/handler/http/,<module>/service/,<module>/repository/mysql/,<module>/_mock/ - Internal RPCs → same structure under
internal/<module>/, but no HTTP handler is generated
Routes are resolved in priority order:
if r.HTTPMethod == "" {
// 1. No annotation → infer from RPC name (Create→POST, List→GET, etc.)
method, echoPath, pathParams = inferRoute(r.Name, moduleName)
} else if strings.Contains(r.HTTPPath, "{") {
// 2. Google API style path template → convert to Echo format
method = r.HTTPMethod
echoPath, pathParams = googlePathToEcho(r.HTTPPath)
} else {
// 3. Plain Echo path (e.g. /v1/orders/:id) → extract :params directly
method = r.HTTPMethod
echoPath = r.HTTPPath
for _, seg := range strings.Split(echoPath, "/") {
if strings.HasPrefix(seg, ":") {
pathParams = append(pathParams, pathParamFromName(strings.TrimPrefix(seg, ":")))
}
}
}googlePathToEcho converts path templates like /v1/{name=examples/*} into Echo-compatible paths /v1/examples/:name:
// "/v1/{name=examples/*}" → "/v1/examples/:name"
// "/v1/{name=examples/*}:search"→ "/v1/examples/:name/search"
// "/v1/{order.name=examples/*}" → "/v1/examples/:name" (Nested=true)A custom method suffix (:search, :cancel) outside {} is detected by scanning for : at brace depth 0, then appended as a path segment.
All Go code templates live in internal/proto/templates/ and are loaded via embed.FS:
//go:embed templates
var protoTmplFS embed.FS
func mustProtoTmpl(name string) string {
b, _ := protoTmplFS.ReadFile("templates/" + name)
return string(b)
}| Template file | Output |
|---|---|
models.tmpl |
models/<module>_dto.go — request/response structs with json/query/validate tags |
interface.tmpl |
<module>/interface.go — service and repository interfaces |
handler.tmpl |
<module>/handler/http/<module>_handler.go — Echo HTTP handler |
handler_test.tmpl |
<module>/handler/http/<module>_handler_test.go — handler unit tests |
service.tmpl |
<module>/service/<module>_service.go — business logic delegation |
service_test.tmpl |
<module>/service/<module>_service_test.go — service unit tests |
repository.tmpl |
<module>/repository/mysql/<module>_repository.go — GORM stub |
mock_service.tmpl |
<module>/_mock/<module>_service_mock.go — mock for tests |
mock_repository.tmpl |
<module>/_mock/<module>_repository_mock.go — mock for tests |
Templates use Go's text/template syntax with a small FuncMap:
funcMap := template.FuncMap{
"lower": strings.ToLower,
"lowerFirst": lowerFirst, // "OrderService" → "orderService"
"methodTitle": ..., // "GET" → "Get" (for nethttp.MethodGet)
}For a proto service OrderService with a mix of public and internal RPCs:
models/
order_dto.go ← shared request/response structs
order/ ← public RPCs
interface.go
handler/http/
order_handler.go
order_handler_test.go
service/
order_service.go
order_service_test.go
repository/mysql/
order_repository.go
_mock/
order_service_mock.go
order_repository_mock.go
internal/order/ ← Internal RPCs (no HTTP handler)
interface.go
service/
order_service.go
order_service_test.go
repository/mysql/
order_repository.go
_mock/
order_service_mock.go
order_repository_mock.go
- Create
api/<module>.protofollowing the Google API style. - Run
a-kit generate <module>. - Wire it up in
main.go:repo := orderRepository.NewOrderServiceMySQLRepository(db) svc := orderService.NewOrderService(repo) orderHTTPHandler.NewOrderServiceHandler(e, svc, mw)
- Implement the repository stub and business logic.
a-kit/
├── main.go entry point
├── cmd/
│ ├── root.go Cobra root command, banner, Execute()
│ ├── create.go `a-kit create` command
│ ├── generate.go `a-kit generate` command
│ └── version.go `a-kit version` command
├── internal/
│ ├── version/
│ │ └── version.go ldflags-injectable version string
│ ├── scaffold/
│ │ ├── scaffold.go project skeleton generator
│ │ ├── templates.go embed.FS loader for scaffold templates
│ │ └── templates/ 22 .tmpl files for project files
│ └── proto/
│ ├── parser.go .proto text → *ProtoFile (no protoc)
│ ├── generator.go *ProtoFile → Go source files
│ ├── parser_test.go table-driven parser unit tests
│ ├── generator_test.go table-driven generator helper tests
│ └── templates/ 9 .tmpl files for module code
└── Makefile build / install with ldflags version injection