This document describes all the patterns available in sdk-api for building event-driven microservices and monoliths. Each pattern has a YAML definition and a Go handler.
Full CRUD for a single model. Five endpoints auto-generated.
YAML:
entry:
- type: crud
model: Product
db: pg-main
table: products
path: /productsGo:
type Product struct {
ID int64 `db:"id,primary,auto" json:"id"`
Name string `db:"name,required" json:"name"`
Price float64 `db:"price" json:"price"`
}
type ProductHooks struct {
runtime.DefaultHooks[Product]
}
func (h *ProductHooks) BeforeCreate(ctx context.Context, req Product) (Product, error) {
// Transform/validate before DB insert
return req, nil
}
func main() {
svc, _ := runtime.New("service.yaml")
pool := svc.Pool("pg-main").(*pgxpool.Pool)
table, _ := db.NewTable[Product](pool, "products")
svc.WithCRUD("Product", runtime.NewCRUDProvider(table, &ProductHooks{}))
svc.RegisterModel("Product", (*Product)(nil))
svc.Run()
}Resulting API:
| Method | Path | Hook integration |
|---|---|---|
| GET | /api/v1/products |
— |
| GET | /api/v1/products/:id |
— |
| POST | /api/v1/products |
BeforeCreate → DB → AfterCreate |
| PATCH | /api/v1/products/:id |
BeforeUpdate → DB → AfterUpdate |
| DELETE | /api/v1/products/:id |
BeforeDelete → DB → AfterDelete |
Disable or replace individual CRUD endpoints.
YAML:
entry:
- type: crud
model: Order
db: pg-main
table: orders
overrides:
list: "onCustomList" # Replace List handler
get: ~ # Default (keep auto-generated)
create: "-" # Disable POST /orders
update: ~ # Default
delete: "-" # Disable DELETE /orders/:idGo:
svc.WithRest("onCustomList", func(c fiber.Ctx) error {
// Custom pagination logic
return c.JSON(fiber.Map{"data": items, "total": total})
})Override values:
""(empty) or~(null) → use auto-generated handler"-"→ don't register this endpoint"handlerName"→ use this handler from the Rest map
A single HTTP endpoint with custom handler.
YAML:
entry:
- type: rest
method: GET
path: /products/:id/transform
handler: onTransformProductGo:
svc.WithRest("onTransformProduct", func(c fiber.Ctx) error {
id := fiber.Params[string](c, "id")
return c.JSON(fiber.Map{"transformed": true, "id": id})
})Wraps a REST handler with BeforeTransform/AfterTransform hooks for input validation/output processing.
YAML:
entry:
- type: rest
method: POST
path: /products/convert
handler: onConvertProductGo:
type ProductHooks struct {
runtime.DefaultHooks[Product]
}
func (h *ProductHooks) BeforeTransform(ctx context.Context, req Product) (Product, error) {
req.Price = req.Price * 1.16 // Apply tax
return req, nil
}
svc.WithRest("onConvertProduct", runtime.WrapTransformHandler(
func(c fiber.Ctx) error {
input := c.Locals("transformed").(Product)
return c.JSON(fiber.Map{"original": input.Name, "price": input.Price})
},
&ProductHooks{},
))BeforeTransform parses the body into T, calls the hook, stores the result in c.Locals("transformed"). AfterTransform is called with the response bytes.
Auto-publishes to NATS after a successful response.
YAML:
entry:
- type: rest
method: POST
path: /orders
handler: onCreateOrder
db: pg-main
nats_publish:
- stream: orders
subject: orders.createdGo:
svc.WithRest("onCreateOrder", func(c fiber.Ctx) error {
// Handler runs first
// If 2xx, SDK auto-publishes c.Body() to NATS
return c.JSON(fiber.Map{"orderID": "123"})
})The wrapNATSPublish wrapper publishes only when the handler returns no error and the status is < 400.
HTTP endpoint that defaults to POST when no method is specified. No JWT by default. Often combined with nats_publish.
YAML:
entry:
- type: webhook
path: /webhooks/sendgrid
handler: onInboundEmail
nats_publish:
- stream: email
subject: email.receivedGo:
svc.WithRest("onInboundEmail", func(c fiber.Ctx) error {
// Process inbound webhook payload
log.Printf("received: %s", string(c.Body()))
return c.JSON(fiber.Map{"received": true})
})Bidirectional real-time communication.
YAML:
entry:
- type: websocket
path: /ws/chat
handler: onChat
auth: trueGo:
svc.WithWS("onChat", func(ctx context.Context, conn *websocket.Conn) error {
for {
mt, msg, err := conn.ReadMessage()
if err != nil { break }
conn.WriteMessage(mt, msg)
}
return nil
})Unidirectional real-time stream.
YAML:
entry:
- type: sse
path: /events/stream
handler: onStream
auth: trueGo:
svc.WithSSE("onStream", func(ctx context.Context, send func(data string)) error {
for i := 0; i < 10; i++ {
send("data: event " + strconv.Itoa(i))
send("")
time.Sleep(1 * time.Second)
}
return nil
})YAML:
entry:
- type: file
method: POST
path: /files/upload
handler: onFileUpload
allowed_types:
- image/png
- application/pdf
max_size: 10MB
storage:
mode: local
path: /data/uploads
- type: file
method: GET
path: /files/:id/download
handler: onFileDownload
storage:
mode: local
path: /data/uploadsGo:
svc.WithRest("onFileUpload", func(c fiber.Ctx) error {
file, _ := c.FormFile("file")
src, _ := file.Open()
defer src.Close()
dst, _ := os.Create("/data/uploads/" + file.Filename)
io.Copy(dst, src)
return c.JSON(fiber.Map{"filename": file.Filename, "size": file.Size})
})
svc.WithRest("onFileDownload", func(c fiber.Ctx) error {
id := fiber.Params[string](c, "id")
f, _ := os.Open("/data/uploads/" + id)
defer f.Close()
c.Set("Content-Disposition", `attachment; filename="`+id+`"`)
return c.SendStream(f)
})The allowed_types validation returns 415 if Content-Type doesn't match. max_size returns 413 if body exceeds limit. Supports wildcard: image/*.
Storage backends can be auto-created from YAML config or manually instantiated:
s3store, _ := server.NewS3Storage(server.S3Config{
Endpoint: "http://minio:9000",
Bucket: "uploads",
AccessKey: "minioadmin",
SecretKey: "minioadmin",
})
local, _ := server.NewLocalStorage("/data/uploads")Process NATS messages as they arrive. Fire-and-forget.
YAML:
exit:
- name: email-sender
subscribe:
stream: orders
subject: orders.confirmed
handler: onOrderConfirmed
max_concurrent: 10Go:
svc.WithExit("onOrderConfirmed", func(ctx context.Context, msg []byte) ([]byte, error) {
var order OrderEvent
json.Unmarshal(msg, &order)
// Send email...
return nil, nil
})Process and respond to a NATS request-reply.
YAML:
exit:
- name: order-validator
subscribe:
stream: orders
subject: orders.validate
handler: onValidateOrder
reply: true
reply_timeout: 30sGo:
svc.WithExit("onValidateOrder", func(ctx context.Context, msg []byte) ([]byte, error) {
var req struct { ID string `json:"id"` }
json.Unmarshal(msg, &req)
valid := req.ID != ""
resp, _ := json.Marshal(fiber.Map{"valid": valid})
return resp, nil // SDK calls msg.Respond(resp)
})Publishing side (Producer):
producer := events.NewProducer[MyType](nc, js, "orders.validate")
resp, err := producer.PublishAndWait(ctx, myData, 5*time.Second)Batch-fetch messages instead of push delivery.
YAML:
exit:
- name: batch-processor
subscribe:
stream: orders
subject: orders.batch
handler: onBatch
max_concurrent: 5
pull_batch: 10 # Fetch 10 messages per poll
consumer_mode: pullThe worker pulls messages in batches of pull_batch size with PullMaxWait timeout.
Publish to NATS on a schedule.
YAML:
cron:
- name: daily-report
schedule: "0 6 * * *"
mode: nats
publish:
stream: cron
subject: cron.daily-reportThe cron publishes an empty payload to the subject. An exit worker can listen for it:
exit:
- name: report-generator
subscribe:
stream: cron
subject: cron.daily-report
handler: onDailyReportCall a Go function on schedule.
YAML:
cron:
- name: cleanup-expired
schedule: "0 */4 * * *"
mode: handler
handler: onCleanupExpiredGo:
svc.WithCron("onCleanupExpired", func(ctx context.Context) error {
// Delete expired records
return nil
})System tick — logs a message, does nothing else.
cron:
- name: health-check
schedule: "@every 1h"
mode: internalDifferent entry endpoints can use different databases.
YAML:
databases:
- name: pg-main
driver: postgres
url: "${PG_URL}"
- name: mysql-audit
driver: mysql
url: "${MYSQL_URL}"
entry:
- type: crud
model: Product
db: pg-main # PostgreSQL for products
- type: crud
model: AuditLog
db: mysql-audit # MySQL for auditGo:
// PostgreSQL table
pgPool := svc.Pool("pg-main").(*pgxpool.Pool)
pgTable, _ := db.NewTable[Product](pgPool, "products")
svc.WithCRUD("Product", runtime.NewCRUDProvider(pgTable, nil))
// MySQL table
sqlPool := runtime.PoolSQL(nil, "mysql-audit")
mysqlTable, _ := db.NewMySQLTable[AuditLog](sqlPool, "audit_logs")
svc.WithCRUD("AuditLog", runtime.NewMySQLCRUDProvider(mysqlTable, nil))Same CRUD pattern but using MySQL driver instead of PostgreSQL.
sqlDB := svc.Pool("mysql-main").(*sql.DB)
table, _ := db.NewMySQLTable[Product](sqlDB, "products")
svc.WithCRUD("Product", runtime.NewMySQLCRUDProvider(table, &ProductHooks{}))NewMySQLCRUDProvider[T] wraps db.MySQLTable[T] into the same CRUDProvider interface.
Same pattern for Turso (SQLite-compatible).
t, _ := db.NewTursoTable[Product]("file://bench.db", "products")
svc.WithCRUD("Product", runtime.NewTursoCRUDProvider(t, &ProductHooks{}))Use different YAML for Docker vs local:
cfgPath := os.Getenv("CONFIG_PATH")
if cfgPath == "" {
cfgPath = "service.yaml"
}
svc, _ := runtime.New(cfgPath)Example service.docker.yaml uses Docker hostnames (postgres:5432, nats:4222) instead of localhost.
The runtime handles shutdown automatically on SIGINT/SIGTERM:
- Stops cron scheduler (waits for running jobs)
- Drains exit workers (waits for in-flight handlers, timeout 5s)
- Drains all NATS connections
- Closes all DB pools
- Stops HTTP server
No manual shutdown code needed.