Navigating the generated application
After crank init, you have a fully wired Go backend service.
This page explains every layer โ where things live, why they are there,
how they connect, and how to extend them.
๐ Table of Contents
- Architecture overview
- Directory layout
- Composition root
- Domain layer
- Application layer
- HTTP adapter
- Persistence adapters
- Unit of Work and Event Bus
- Cross-cutting packages
- Request lifecycle
- Feature modules
- Code generation
- Testing patterns
- Configuration deep dive
- External documentation
๐๏ธ Architecture overview
The generated project follows a Domain-Driven Design layered architecture.
Every layer depends only on the layer below it โ never upward.
๐ Key principles
| Principle | How itโs enforced |
|---|---|
| ๐งน Domain is pure Go | Zero framework imports in internal/domain/ โ no HTTP, no database drivers, no serialization tags |
| โฌ๏ธ Dependencies point inward | Adapters implement interfaces defined by the domain; the domain never imports adapters |
| ๐ฏ Application coordinates | Handlers load aggregates, call domain methods, and route persistence + events through ports |
| ๐ Composition root | cmd/server/main.go is the only place concrete types meet interfaces |
๐ Dependency rule
HTTP Handlers โ Application Handlers โ Domain (Aggregates + Ports) โ Infrastructure Adapters
โ โ โ
โโโโ DTOs live โโโโโโโ โ
here โ
โ
Infrastructure adapters (Persistence, EventBus, etc.) implement โ
interfaces defined in the Domain โ the domain never โ
imports infrastructure. โ
๐ Directory layout
myapp/
โโโ ๐ cmd/server/ โ Entry point + composition root
โโโ ๐ configs/ โ Config defaults (committed)
โโโ ๐ internal/
โ โโโ ๐ adapters/ โ Infrastructure implementations
โ โ โโโ ๐ eventbus/ โ In-process event bus
โ โ โโโ ๐ http/web/ โ Echo handlers + middleware
โ โ โโโ ๐ persistence/ โ memory/, bun/, gorm/ repositories
โ โ โโโ ๐ cache/ โ Redis client (redis feature)
โ โ โโโ ๐ uow/ โ Unit of Work implementations
โ โโโ ๐ application/ โ CQRS use cases
โ โ โโโ ๐ user/
โ โโโ ๐ config/ โ Viper + env config loading
โ โโโ ๐ domain/ โ Pure domain model
โ โ โโโ ๐ shared/ โ DomainEvent interface + codecs
โ โ โโโ ๐ user/ โ Aggregate, ID, events, errors, port
โ โโโ ๐ model/ โ Shared DTOs (APIError)
โ โโโ ๐ ports/ โ Cross-cutting interfaces
โ โโโ ๐ validator/ โ Request validation
โโโ ๐ pkg/logging/ โ slog helpers + redaction
โโโ ๐ docs/ โ Generated Swagger spec
โโโ ๐ migrations/ โ SQL migrations (with ORM)
โโโ ๐ .crank.yaml โ Project manifest (do not delete)
โโโ ๐ .env.example โ Local env template
โโโ ๐ .air.toml โ Live-reload config
โโโ ๐ Dockerfile
โโโ ๐ Makefile โ Project-specific targets
๐ก Tip: The
internal/directory enforces Goโs visibility rules โ
nothing outside the module can import packages underinternal/.
๐ Composition root
This is the single entry point (cmd/server/main.go) and the only place where concrete types
are wired together. It performs these steps in order:
This is also where feature clients (Redis, Temporal, MongoDB, etc.) are wired.
The generated code uses marker comments (// crank:config-*, // crank:http-*)
so that crank add and crank make handler can inject new wiring without
breaking existing code.
๐งฉ What gets wired (feature-dependent)
| Client | Initialized when |
|---|---|
eventbus.NewInMemory() |
Always |
memory.NewUserRepository() |
No ORM feature |
bun.NewUserRepository(db) |
bun feature |
gorm.NewUserRepository(gormDB) |
gorm feature |
redisclient.NewClient(cfg.Redis) |
redis feature |
mongodb.NewClient(ctx, cfg.MongoDB) |
mongodb feature |
qdrantclient.NewClient(ctx, cfg.Qdrant) |
qdrant feature |
temporal.NewClient(cfg.Temporal, logger) |
temporal feature |
telemetry.NewProvider(ctx, cfg.Telemetry) |
otel feature |
| Outbox UoW + Worker | outbox feature |
๐ง Domain layer
The domain layer (internal/domain/) is the heart of the application โ pure Go with zero
external dependencies. Each business concept lives in its own package.
๐ฆ Package anatomy
Taking the generated internal/domain/user/ package:
| File | ๐ฏ Purpose |
|---|---|
user.go |
Aggregate root struct + constructor + behavior methods + event recording |
user_id.go |
Typed value object โ prevents passing the wrong ID type at compile time |
events.go |
Domain events: UserCreated, UserUpdated, UserDeleted |
errors.go |
Sentinel errors: ErrUserNotFound, ErrInvalidUser |
repository.go |
Port (interface) โ implementations live in adapters |
๐งฌ The aggregate
type User struct {
id UserID
name string
email string
password string // bcrypt hash (only with auth feature)
createdAt time.Time
updatedAt time.Time
events []shared.DomainEvent
}
โจ Design decisions
| Decision | Rationale |
|---|---|
| ๐ Unexported fields | The aggregate controls its own state through methods. No one can set user.name directly. |
| ๐ท๏ธ No struct tags | No json, db, validate, gorm, or bun tags. Each layer defines its own DTOs. |
| โ Methods return errors | Constructors and mutators validate invariants (empty name โ ErrInvalidUser). |
| ๐ก Event sourcing ready | Behavior methods record events via recordEvent. Call PullEvents() to collect and dispatch. |
๐จ Creating a new aggregate
id, _ := user.NewUserID("usr_123")
u, err := user.NewUser(id, "Alice", "alice@example.com")
// u.PullEvents() โ [UserCreated{...}]
๐ Shared domain event infrastructure
The internal/domain/shared/ package provides:
| Component | Purpose |
|---|---|
DomainEvent interface |
EventName() string, OccurredAt() time.Time |
EncodeEvent() / DecodeEvent() |
JSON serialize/deserialize for transport |
EventEnvelope |
Wire format: name + timestamp + JSON body |
| Event factory registry | Maps event names to constructors for reliable deserialization |
โ๏ธ Application layer
The application layer (internal/application/) implements use cases using the CQRS pattern โ
Commands mutate state, Queries read state. They never mix.
๐ฆ Package anatomy
๐ฎ Command handler pattern
func (h *CommandHandler) HandleCreate(ctx context.Context, cmd CreateUserCommand) (*user.User, error) {
id, err := user.NewUserID(cmd.ID)
u, err := user.NewUser(id, cmd.Name, cmd.Email)
err := h.uow.SaveAndPublish(ctx,
func(ctx context.Context) error { return h.repo.Save(ctx, u) },
u.PullEvents(),
)
return u, nil
}
โก The command handler never calls
repo.Savedirectly โ it goes through
the Unit of Work to ensure the aggregate save and event publication
are handled atomically.
โ Why CQRS?
| Benefit | Explanation |
|---|---|
| ๐ Optimize reads independently | Swap in a read-optimized store (cached view, materialized table) without touching command logic |
| ๐ฏ Handlers stay focused | Command handlers handle mutations + events; query handlers do pure reads |
| ๐งฉ Clear intent | A POST handler calls cmd.HandleCreate, a GET handler calls qry.HandleGet |
๐ HTTP adapter
The presentation layer (internal/adapters/http/web/) โ translates HTTP requests to application commands/queries
and results back to JSON.
๐ฆ Files at a glance
| File | ๐ฏ Role |
|---|---|
server.go |
Creates Echo instance, wires the smart binder, exposes GET /health |
routes.go |
Central route aggregator โ generated handlers splice in via marker comments |
user_handler.go |
CRUD handler โ DTOs, Swagger annotations, error mapping |
middleware/logging.go |
Request-scoped logger injection |
middleware/auth.go |
JWT middleware (with auth feature) |
๐ง The smart binder
The generated server replaces Echoโs default binder with a custom echoBinder
that automatically validates after every c.Bind():
type echoBinder struct {
defaultBinder echo.Binder
logger *slog.Logger
}
func (b *echoBinder) Bind(i any, c echo.Context) error {
if err := b.defaultBinder.Bind(i, c); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := validator.Struct(i); err != nil {
return err // automatically formatted as ValidationError
}
return nil
}
This means handlers never call the validator explicitly:
func (h *UserHandler) Create(c echo.Context) error {
var in userDTO
if err := c.Bind(&in); err != nil { // Bind + validate in one call
return err
}
// โ
`in` is guaranteed valid past this point
out, err := h.cmd.HandleCreate(c.Request().Context(), in.toCreateCommand())
// ...
}
๐จ DTO pattern
HTTP handlers define their own DTOs with JSON and validation tags โ
they are not the domain aggregate:
type userDTO struct {
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
}
func (d userDTO) toCreateCommand() appuser.CreateUserCommand {
return appuser.CreateUserCommand{ID: d.ID, Name: d.Name, Email: d.Email}
}
๐ Route wiring
routes.go uses marker comments to make code generation idempotent:
type MountConfig struct {
UserHandler *UserHandler
// crank:http-fields โ new handlers splice fields here
}
func Mount(e *echo.Echo, cfg MountConfig) {
g := e.Group("/users")
cfg.UserHandler.Register(g)
// crank:http-register โ new handlers splice registrations here
}
๐จ Error handling
The composition root installs a custom HTTPErrorHandler that:
- ๐ก ValidationError โ 422 with field-level error details
- ๐ด echo.HTTPError โ Appropriate HTTP status code
- โซ Unhandled errors โ 500 Internal Server Error
Handlers map domain errors to HTTP status codes:
if errors.Is(err, user.ErrUserNotFound) {
return c.JSON(http.StatusNotFound, model.APIError{Error: "user not found"})
}
๐ Persistence adapters
These (internal/adapters/persistence/) implement the domainโs Repository interface. The composition root
decides which one to wire in โ no application code changes.
๐๏ธ Available adapters
| Adapter | Location | ๐ฏ Use Case |
|---|---|---|
| ๐ง In-memory | persistence/memory/ |
Unit tests, local dev without database |
| ๐ GORM | persistence/gorm/ |
Production PostgreSQL (with gorm feature) |
| ๐ Bun | persistence/bun/ |
Production PostgreSQL (with bun feature) |
The in-memory repository is a thread-safe sync.RWMutex-guarded map โ
perfect for fast tests:
type UserRepository struct {
mu sync.RWMutex
byID map[string]*user.User
byEml map[string]*user.User
}
๐ Switching adapters
To switch from in-memory to PostgreSQL, change only the composition root:
// In-memory (default)
userRepo := memory.NewUserRepository()
// Bun
userRepo := bun.NewUserRepository(db)
// GORM
userRepo := gorm.NewUserRepository(gormDB)
No application or HTTP code changes โ they depend only on the domainโs Repository interface.
๐ Unit of Work and Event Bus
These two abstractions ensure atomicity between persisting an aggregate
and publishing its domain events.
๐ The core flow
๐พ In-memory Unit of Work
The default implementation runs save then publish sequentially:
| Step | Action | On failure |
|---|---|---|
| 1 | repo.Save(ctx, user) |
โ Short-circuit โ events not published |
| 2 | bus.Publish(ctx, events) |
โ ๏ธ Logged โ save stands |
๐ค Outbox Unit of Work (with outbox feature)
Both operations share a database transaction:
- Aggregate saved to its table
- Domain events written to
outbox_eventstable in same transaction - Background worker polls outbox table and relays events to the bus
- Events deleted after successful publication (at-least-once delivery)
This eliminates the window between โsave succeededโ and โpublish failedโ
that the in-memory UoW has.
๐ก Event Bus
The in-memory bus is a simple pub/sub:
bus := eventbus.NewInMemory()
bus.Subscribe(func(ctx context.Context, ev shared.DomainEvent) error {
slog.Default().Info("event received", "event", ev.EventName())
return nil
})
Subscribers are called synchronously in registration order. A failing
subscriber does not block others โ the error is logged and the next one runs.
๐ง Cross-cutting packages
โ๏ธ Configuration (internal/config/)
Priority: env vars > .env file > configs/config.yaml
| File | Committed? | Use |
|---|---|---|
configs/config.yaml |
โ Yes | Non-secret defaults (host, port, log level) |
.env.example |
โ Yes | Template for developers |
.env |
โ No | Local secrets, gitignored |
Secret fields use env:"SECRET_NAME" tags and are loaded from .env
or environment variables. Feature configs are injected via marker comments.
โ
Validation (internal/validator/)
Preconfigured go-playground/validator singleton:
- Uses JSON tag names in error messages
- Automatically called by the smart Echo binder
- Extensible โ add custom validators in
init():
validate.RegisterValidation("notblank", func(fl validator.FieldLevel) bool {
return strings.TrimSpace(fl.Field().String()) != ""
})
๐ Model (internal/model/)
Shared response types used by every HTTP handler:
type APIError struct {
Error string `json:"error"`
Details any `json:"details,omitempty"`
}
๐ Logging (pkg/logging/)
Helpers built on log/slog:
| Function | Purpose |
|---|---|
logging.New(level, addSource) |
Creates a configured logger |
logging.FromContext(ctx) |
Retrieves request-scoped logger |
logging.Redacted(key, value) |
Redacts sensitive values (passwords, tokens) |
๐ Ports (internal/ports/)
Cross-cutting interfaces that adapters implement:
| Interface | Purpose | Default implementation |
|---|---|---|
EventBus |
Publish domain events | In-memory bus |
UnitOfWork |
Atomic save + publish | In-memory UoW |
Hasher |
Hash/compare passwords | BCrypt (with auth) |
TokenService |
Issue/refresh/validate JWT | JWT service (with auth) |
Cache |
Get/Set/Delete cache entries | (with redis) |
Cipher |
Encrypt/Decrypt data | AES-256-GCM (with crypto) |
TracerProvider |
Create OTel tracers | Stdout exporter (with otel) |
๐ก Request lifecycle
POST /users
{"name":"Alice","email":"a@b.com"}
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Echo Router โ
โ Matches POST /users โ UserHandler.Create โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ echoBinder.Bind(&userDTO) โ
โ โ
โ 1. DefaultBinder.Bind() โ parse JSON into struct โ
โ 2. validator.Struct() โ validate tags โ
โ โ
โ โโ On failure โโโ 422 ValidationError โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ UserHandler.Create(c) โ
โ โ
โ userDTO.toCreateCommand() โ CreateUserCommand โ
โ cmd.HandleCreate(ctx, cmd) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application CommandHandler โ
โ โ
โ 1. user.NewUserID(cmd.ID) โ
โ 2. user.NewUser(id, name, email) โ
โ โ User aggregate + UserCreated event โ
โ 3. uow.SaveAndPublish(ctx, saveFn, events) โ
โ โโโ repo.Save(ctx, user) โ
โ โโโ bus.Publish(ctx, UserCreated{...}) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Response โ
โ โ
โ toUserDTO(result) โ 201 Created โ
โ { "id":"...", "name":"Alice", "email":"a@b.com" } โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐งฉ Feature modules
Each optional feature adds files to the project. Here is what every feature
contributes and where to find it.
๐ Auth โ JWT authentication + bcrypt
| File | Purpose |
|---|---|
internal/ports/hasher.go |
Password hashing interface |
internal/ports/tokenservice.go |
Token issue/refresh/validation interface |
internal/adapters/crypto/bcrypt_hasher.go |
BCrypt hashing |
internal/adapters/crypto/jwt_token_service.go |
JWT token management |
internal/adapters/http/web/auth_handler.go |
/auth/register, /auth/login, /auth/refresh, /me |
internal/adapters/http/web/middleware/auth.go |
JWTAuth() middleware |
internal/domain/user/email.go |
Email value object |
internal/domain/user/password.go |
Password value object |
Endpoints:
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/register |
โ | Create account + return tokens |
| POST | /auth/login |
โ | Authenticate + return tokens |
| POST | /auth/refresh |
โ | Exchange refresh token for new pair |
| GET | /me |
โ Bearer | Current user ID from JWT |
Protecting custom routes:
e.GET("/admin", adminHandler, middleware.JWTAuth(tokens))
๐๏ธ Bun ORM โ PostgreSQL via Bun
| File | Purpose |
|---|---|
internal/adapters/persistence/bun/db.go |
PostgreSQL connection with pgdriver |
internal/adapters/persistence/bun/migrate.go |
Migration runner |
internal/adapters/persistence/bun/user_repository.go |
Bun-backed user repository |
migrations/000001_init.up.sql |
Initial schema |
migrations/000001_init.down.sql |
Schema rollback |
๐ bun.uptrace.dev
๐๏ธ GORM โ PostgreSQL via GORM
| File | Purpose |
|---|---|
internal/adapters/persistence/gorm/db.go |
PostgreSQL connection |
internal/adapters/persistence/gorm/migrate.go |
Migration runner |
internal/adapters/persistence/gorm/user_repository.go |
GORM-backed user repository |
migrations/000001_init.up.sql |
Initial schema |
migrations/000001_init.down.sql |
Schema rollback |
๐ gorm.io/docs
โก Redis โ Caching client
| File | Purpose |
|---|---|
internal/ports/cache.go |
Cache interface |
internal/adapters/cache/redis/client.go |
redis.Client connection |
Wired in the composition root as rdb. ๐ redis.uptrace.dev
๐ MongoDB โ Document database client
| File | Purpose |
|---|---|
internal/adapters/persistence/mongodb/client.go |
mongo.Client connection |
Wired as mdb. Access via mdb.Client().Database("name").Collection("coll").
๐ mongodb.com/docs/drivers/go
๐ง Qdrant โ Vector database client
| File | Purpose |
|---|---|
internal/adapters/persistence/qdrant/client.go |
Qdrant gRPC client |
โฐ Temporal โ Workflow orchestration
| File | Purpose |
|---|---|
internal/adapters/temporal/client.go |
Temporal client + slog bridge |
internal/adapters/temporal/worker.go |
Worker with marker-based registration |
internal/adapters/temporal/workflow/greeting.go |
Example workflow |
internal/adapters/temporal/activity/greeting.go |
Example activity |
cmd/worker/main.go |
Standalone worker entry |
Generate new workflows:
crank make workflow OrderFulfillment order_id:uuid
crank make activity ChargeCard amount:float --tests
๐ OpenTelemetry โ Distributed tracing
| File | Purpose |
|---|---|
internal/ports/tracer.go |
TracerProvider interface |
internal/adapters/telemetry/otel.go |
OTel SDK setup (stdout exporter) |
internal/adapters/http/web/middleware/tracing.go |
HTTP tracing middleware |
Emits spans to stdout by default. Swap to OTLP for production.
๐ opentelemetry.io/docs/languages/go
๐ Crypto โ AES-256-GCM encryption
| File | Purpose |
|---|---|
internal/ports/cipher.go |
Encrypt/Decrypt interface |
internal/adapters/crypto/aesgcm/cipher.go |
AES-256-GCM implementation |
Generate a strong key: openssl rand -base64 32
๐จ Views โ React SPA
| File | Purpose |
|---|---|
internal/adapters/http/web/views.go |
SPA serving + Vite proxy |
static/embed.go |
Embedded static assets |
src/App.jsx |
React application |
vite.config.js |
Vite dev server config |
Toggle with views.enabled in config. Set views.dev_server for HMR.
๐ค Outbox โ Transactional outbox pattern
| File | Purpose |
|---|---|
internal/domain/outbox/event.go |
Outbox event domain type |
internal/domain/outbox/repository.go |
Outbox repository port |
internal/adapters/persistence/bun/outbox_repository.go |
Bun-backed outbox repository |
internal/adapters/persistence/gorm/outbox_repository.go |
GORM-backed outbox repository |
internal/adapters/outbox/bun_uow.go |
Bun transactional UoW |
internal/adapters/outbox/gorm_uow.go |
GORM transactional UoW |
internal/adapters/outbox/worker.go |
Background poller + publisher |
migrations/000002_add_outbox_events.up.sql |
Outbox table schema |
Requires gorm or bun. Replaces in-memory UoW with transaction-backed.
๐ญ Code generation
What crank make scaffold produces (crank make)
crank make scaffold Product title:string price:float --tests
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ crank make scaffold โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ ๐ internal/domain/product/ โ
โ โโโ product.go โ Aggregate root โ
โ โโโ product_id.go โ Typed ID โ
โ โโโ events.go โ Domain events โ
โ โโโ errors.go โ Sentinel errors โ
โ โโโ repository.go โ Port โ
โ โ
โ ๐ internal/application/product/ โ
โ โโโ commands.go โ Create/Update/Delete โ
โ โโโ command_handler.go โ Command execution โ
โ โโโ queries.go โ Get/List โ
โ โโโ query_handler.go โ Query execution โ
โ โ
โ ๐ internal/adapters/persistence/memory/ โ
โ โโโ product_repository.go โ In-memory implementation โ
โ โ
โ ๐ internal/adapters/http/web/ โ
โ โโโ product_handler.go โ Echo handler + DTOs โ
โ โ
โ In routes.go: โ Route wiring (idempotent) โ
โ In migrations/: โ SQL migration (bun only) โ
โ _test.go files: โ With --tests flag โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐ What each kind generates
| Kind | ๐ฏ Primary artifact | ๐ Dependencies generated |
|---|---|---|
model |
Domain aggregate, ID, events, errors, port | โ |
repository |
Repository implementation | Domain model (if missing) |
service |
Commands, queries, handlers | Domain + repository (if missing) |
handler |
HTTP handler + route wiring | Domain + service + repo (skipped with --only) |
scaffold |
Everything above | Everything above |
workflow |
Temporal workflow + registration | โ |
activity |
Temporal activity + registration | โ |
migration |
SQL up/down pair | โ |
โ ๏ธ Idempotency: Running
crank make scaffold Producttwice errors on
the primary artifact but skips existing dependency files. Use--force
to overwrite. Route wiring is idempotent โ no duplicate registrations.
๐งช Testing patterns
The in-memory adapters make it possible to test every layer without a database.
๐งช Testing the domain
No mocks needed โ pure Go types:
func TestNewUser(t *testing.T) {
id, _ := user.NewUserID("test-1")
u, err := user.NewUser(id, "Alice", "alice@example.com")
assert.NoError(t, err)
assert.Equal(t, "Alice", u.Name())
events := u.PullEvents()
assert.Len(t, events, 1)
assert.Equal(t, "user.created", events[0].EventName())
}
๐งช Testing application handlers
Wire in-memory adapters directly โ no database required:
func TestCreateUser_CommandHandler(t *testing.T) {
repo := memory.NewUserRepository()
bus := eventbus.NewInMemory()
uow := uow.NewInMemoryUoW(bus)
handler := userapp.NewCommandHandler(repo, uow)
user, err := handler.HandleCreate(context.Background(), user.CreateUserCommand{
ID: "test-1", Name: "Alice", Email: "alice@example.com",
})
assert.NoError(t, err)
assert.Equal(t, "Alice", user.Name())
}
๐งช Testing HTTP handlers
Use Echoโs httptest helpers:
func TestUserHandler_Create(t *testing.T) {
// Wire in-memory adapters
repo := memory.NewUserRepository()
bus := eventbus.NewInMemory()
uow := uow.NewInMemoryUoW(bus)
cmd := userapp.NewCommandHandler(repo, uow)
qry := userapp.NewQueryHandler(repo)
handler := web.NewUserHandler(cmd, qry)
// Echo test
e := echo.New()
body := `{"name":"Alice","email":"a@b.com"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := handler.Create(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
}
๐ Test coverage strategy
| Layer | Approach | ๐ฏ What to test |
|---|---|---|
| ๐ง Domain | Pure Go unit tests | Invariants, event recording, error cases |
| โ๏ธ Application | In-memory repo + bus | Command/query logic, event publication |
| ๐ HTTP | Echo httptest helpers |
Routing, status codes, validation, response shapes |
| ๐ Persistence | Real DB or in-memory | Save/get/delete round-trips |
โ๏ธ Configuration deep dive
๐ฅ How config loads
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ godotenv.Load() โ
โ Reads .env into process environment โ
โ (silently skipped if .env doesn't exist) โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ viper.ReadInConfig() โ
โ Reads configs/config.yaml โ
โ Sets defaults via setDefaults() โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ env.ParseWithOptions(&cfg) โ
โ Overlays env vars on fields tagged env:"..." โ
โ (typically secrets only) โ
โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โ
Final Config
โ Adding a new config field
- Add the field to
Configstruct ininternal/config/config.go - Add a Viper default in
setDefaults() - Add the YAML key to
configs/config.yaml - If itโs a secret, add
env:"VAR_NAME"tag and document in.env.example
๐ง Feature config injection
When you run crank add redis, new config fields are injected at marker comments:
type Config struct {
App AppConfig `mapstructure:"app"`
// crank:config-fields โ RedisConfig inserted here
Logging LoggingConfig `mapstructure:"logging"`
}
The injection is idempotent โ adding the same feature twice is a no-op.
๐ External documentation
crank โ Scaffold. Build. Ship.