Welcome back to the Tech Radar bulletin. Last week we dissected how Kratos and Dapr v1.15 solve State Collisions via ETags. This week we go one layer deeper: how do you structure the entire codebase so that Kratos, Wire, and Dapr Pub/Sub compose cleanly — and how do you keep that architecture testable, resilient, and production-safe?
1. The Four Layers of Kratos Clean Architecture
Answer-first: Kratos enforces a four-layer Clean Architecture — api, service, biz, and data — where business logic in biz is completely isolated from transport and infrastructure. Each layer communicates only with the layer adjacent to it, and only through interfaces.
This is not a stylistic choice. It is a hard constraint baked into the kratos-layout template:
| Layer | Responsibility | What It MUST NOT Touch |
|---|---|---|
api | Protobuf definitions — generates HTTP & gRPC code | Business logic, DB |
service | Adapter — maps DTO ↔ Domain Model, calls biz | *gorm.DB, Redis |
biz | Domain Models, Usecases, Repository interfaces | Any concrete DB driver |
data | Implements biz interfaces — GORM, Redis, Dapr SDK | Business rules |
The Critical Boundary: biz Never Sees *gorm.DB
The most common anti-pattern in Kratos projects is leaking *gorm.DB directly into the biz layer. This violates the Dependency Inversion Principle and makes unit testing impossible without a live database.
The correct pattern: biz declares an interface, data implements it.
// internal/biz/order.go — biz defines the contract
type OrderRepo interface {
CreateOrder(ctx context.Context, o *Order) error
}
type OrderUsecase struct {
repo OrderRepo
}
// internal/data/order.go — data implements it
type orderRepo struct {
data *Data // holds *gorm.DB internally
}
func (r *orderRepo) CreateOrder(ctx context.Context, o *biz.Order) error {
return r.data.db.WithContext(ctx).Create(o).Error
}
biz is now completely database-agnostic. Swap PostgreSQL for MySQL — only data changes.
2. Google Wire: Compile-Time Dependency Injection
Answer-first: Wire is a compile-time code generator that resolves the full dependency graph of your Kratos service. It eliminates manual wiring, catches missing dependencies at build time (not at runtime), and produces zero-overhead initialization code.
How Wire Structures Kratos Providers
Each layer exposes a ProviderSet that declares its constructors:
// internal/data/data.go
var ProviderSet = wire.NewSet(NewData, NewOrderRepo)
// internal/biz/biz.go
var ProviderSet = wire.NewSet(NewOrderUsecase)
// internal/service/service.go
var ProviderSet = wire.NewSet(NewOrderService)
The entry point wires them all together:
// cmd/server/wire.go
//go:build wireinject
func initApp(cfg *conf.Bootstrap, logger log.Logger) (*kratos.App, func(), error) {
panic(wire.Build(
server.ProviderSet,
data.ProviderSet,
biz.ProviderSet,
service.ProviderSet,
))
}
Run wire gen ./cmd/server/ and Wire produces wire_gen.go — a regular Go file with all constructors called in the correct order. No reflection. No runtime cost.
Common Wire Pitfall AI Tools Miss
AI code generators (ChatGPT, Copilot) frequently generate Wire setup that compiles but silently creates duplicate singletons — for example, initializing two separate *gorm.DB connections because NewDB is listed in two different ProviderSets. Always verify wire_gen.go after generation and confirm each dependency appears exactly once in the final output.
3. Dapr Pub/Sub: Decoupling the Event Bus from Your Code
Answer-first: Dapr’s Pub/Sub building block abstracts the message broker (Redis Streams, Kafka, RabbitMQ) behind a sidecar API. Your Kratos service publishes and subscribes using the Dapr Go SDK — the broker is a YAML config file, not a code dependency.
Publishing from the biz Layer
Inject the Dapr client as an EventPublisher interface (defined in biz, implemented in data):
// internal/biz/order.go — interface stays in biz
type EventPublisher interface {
PublishOrderCreated(ctx context.Context, order *Order) error
}
func (uc *OrderUsecase) CreateOrder(ctx context.Context, req *CreateOrderReq) error {
order := &Order{ /* ... */ }
if err := uc.repo.CreateOrder(ctx, order); err != nil {
return err
}
// ctx carries the traceparent header — Dapr propagates it into CloudEvents
return uc.publisher.PublishOrderCreated(ctx, order)
}
// internal/data/publisher.go — data wraps Dapr SDK
type daprPublisher struct {
client dapr.Client
}
func (p *daprPublisher) PublishOrderCreated(ctx context.Context, o *biz.Order) error {
return p.client.PublishEvent(ctx, "order-pubsub", "order.created", o)
}
Subscribing via Programmatic Endpoint
Dapr discovers subscriptions at startup by calling GET /dapr/subscribe on your service. Register this route in the Kratos HTTP server:
// Must return this exact JSON structure
[
{
"pubsubname": "order-pubsub",
"topic": "order.created",
"route": "/api/v1/orders/webhook"
}
]
Dapr then delivers events as POST /api/v1/orders/webhook. Parse the CloudEvents envelope — do not read raw body bytes:
func (s *OrderService) HandleOrderCreatedWebhook(w http.ResponseWriter, r *http.Request) {
var ce cloudevents.Event
if err := json.NewDecoder(r.Body).Decode(&ce); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var payload biz.Order
_ = ce.DataAs(&payload)
// process...
w.WriteHeader(http.StatusOK)
}
Important: To permanently drop a malformed message (stop Dapr from retrying), return HTTP 200 with body {"status":"DROP"}. Return HTTP 500 to trigger Dapr’s retry policy from resiliency.yaml.
4. The Dual-Write Problem: Dapr Transactional Outbox
Answer-first: Saving to the database and publishing an event are two separate I/O operations. If the broker is down after the DB write succeeds, the event is lost. Dapr v1.12+ includes a built-in Transactional Outbox that makes both operations atomic — no custom outbox table or polling worker needed.
The Classic Failure Mode
Most Kratos services call db.Create() then client.PublishEvent() sequentially. If the broker is unavailable between those two calls, the DB record exists but no downstream service is notified. The system is now silently inconsistent.
Dapr’s Built-In Solution
Enable outbox on the State Store component YAML:
# components/statestore.yaml
metadata:
- name: outboxPublishPubsub
value: "order-pubsub"
- name: outboxPublishTopic
value: "order.created"
Then replace the two-step write with a single transactional call:
ops := []*dapr.StateOperation{
{
Type: dapr.StateOperationTypeUpsert,
Item: &dapr.SetStateItem{Key: orderKey, Value: orderData},
},
}
// Dapr guarantees: DB write + event publish = one ACID transaction
err := client.ExecuteStateTransaction(ctx, "statestore", meta, ops)
If the broker is temporarily unreachable, Dapr retries publishing until it succeeds. Your code has zero retry logic to maintain.
5. Q&A: Production Gotchas
Does Dapr Pub/Sub guarantee Exactly-Once delivery?
biz handler must implement idempotency. Extract the id field from the incoming CloudEvent and check it against your database (GORM FirstOrCreate or a Redis SET NX) before executing business logic. If the ID already exists, return HTTP 200 — Dapr will not redeliver.How do you unit test the biz layer without running a Dapr sidecar?
EventPublisher is an interface defined in biz, you can mock it with gomock in tests. The real Dapr SDK client lives entirely in data. Your biz unit tests never touch the sidecar — they run as fast as any plain Go test.How do you propagate Distributed Tracing through Dapr Pub/Sub?
tracing.Server() middleware extracts the traceparent header from incoming HTTP requests into context.Context. Pass that exact ctx to every Dapr call — client.PublishEvent(ctx, ...), client.ExecuteStateTransaction(ctx, ...). Dapr embeds the trace context into the CloudEvents envelope, so downstream subscribers receive a correlated span automatically.How do you configure Graceful Shutdown between Kratos and the Dapr sidecar?
terminationGracePeriodSeconds on the Pod to a value greater than dapr.io/graceful-shutdown-seconds. This ensures the sidecar stays alive long enough for Kratos’s Server.Stop() to finish draining in-flight webhook events before the sidecar exits.AI tools generated a package called kratos/v2/transport/dapr — does it exist?
kratos/v2/transport/dapr package. AI code generators hallucinate this integration layer frequently. The correct approach is to use the standard dapr/go-sdk client, wrap it behind a biz-owned interface, and inject it via Wire. There is no Kratos-native Dapr transport module.Continue the series with our deep dives on Microservices with Dapr and the full System Design Series. The next Radar will cover Dapr Workflow and the Actor model for stateful orchestration.
📬 Nháºn Tech Radar hà ng tuần — không spam, chỉ signal: Subscribe tại đây.