For engineers coming from Magento PHP, the shift to Go microservices isn’t just a language change — it’s a fundamentally different way of organizing code. Magento has controllers, models, blocks, helpers, and plugins. Go with Kratos v2 has exactly five layers, each with a precisely defined responsibility.
Answer-first: A production Go microservice on this platform follows the Kratos v2 directory convention (api/ → cmd/ → internal/biz/ → internal/data/ → internal/server/), uses Google Wire for compile-time dependency injection, exposes both HTTP and gRPC simultaneously on different ports (8xxx/9xxx), and imports a shared common library at v1.9.5 that provides outbox, caching, worker, metrics, and logging — standardized across all 21 services.
1. The 5-Layer Directory Structure
Every one of the 21 services follows the same layout:
order-service/
├── api/
│ └── order/
│ └── v1/
│ ├── order.proto ← gRPC + HTTP API definition
│ ├── order_types.proto ← Shared enums (OrderStatus, etc.)
│ └── order_errors.proto ← Error codes
│
├── cmd/
│ └── order-service/
│ ├── main.go ← Entry point
│ ├── wire.go ← Wire injection declarations (build tag)
│ └── wire_gen.go ← Auto-generated by wire command
│
├── internal/
│ ├── biz/ ← Business logic (use cases, domain entities)
│ │ ├── order.go ← Order domain entity + business rules
│ │ ├── order_usecase.go ← Use cases: CreateOrder, CancelOrder...
│ │ └── order_repo.go ← Repository interface (not implementation)
│ │
│ ├── data/ ← Data access (repository implementations)
│ │ ├── order_repo.go ← PostgreSQL implementation
│ │ ├── order_cache.go ← Redis cache layer
│ │ └── data.go ← DB connection, Redis client setup
│ │
│ ├── service/ ← gRPC/HTTP handlers
│ │ └── order.go ← Maps proto requests → biz use cases
│ │
│ └── server/
│ ├── grpc.go ← gRPC server (port 9001)
│ └── http.go ← HTTP server (port 8001)
│
├── migrations/
│ └── 00001_create_orders.sql ← Goose migration
│
└── go.mod ← Module: gitlab.com/ta-microservices/order-service
What each layer does:
api/: The contract. Define your proto here first — before writing any Go code. This layer has no Go code, only.protofiles.cmd/: The entry point and Wire injection root.main.gois typically < 20 lines. All initialization is delegated to Wire.internal/biz/: Pure business logic. No database calls, no HTTP handlers. Biz packages import only domain types and repository interfaces.internal/data/: The repository implementations. Database queries live here. This is where PostgreSQL, Redis, and Dapr event publishing happen.internal/service/: The bridge between transport and business logic. Receives a proto request, calls biz use cases, returns a proto response.internal/server/: Sets up the HTTP and gRPC servers with middleware, starts them.
This separation is not ceremonial. It means: biz never knows it’s running behind gRPC. You can add a CLI transport, a queue consumer, or a test harness that calls biz directly — without changing business logic.
2. Wire: Compile-Time Dependency Injection
In Magento, the dependency injection container is runtime — Magento builds the DI graph when the application boots, resolving dependencies from XML configuration files. This is slow, error-prone, and generates bugs that only appear at runtime.
Go Wire solves this at compile time. The dependency graph is code-generated before the binary is built. If a dependency is missing, the build fails — not a runtime error.
Here’s how it works for the Order Service:
// cmd/order-service/wire.go
//go:build wireinject
// +build wireinject
package main
import (
"github.com/go-kratos/kratos/v2"
"github.com/google/wire"
"gitlab.com/ta-microservices/order-service/internal/biz"
"gitlab.com/ta-microservices/order-service/internal/data"
"gitlab.com/ta-microservices/order-service/internal/server"
"gitlab.com/ta-microservices/order-service/internal/service"
)
func initApp(logger log.Logger, conf *conf.Bootstrap) (*kratos.App, func(), error) {
wire.Build(
data.ProviderSet, // DB connection, Redis, repo implementations
biz.ProviderSet, // Use cases
service.ProviderSet, // gRPC/HTTP service handlers
server.ProviderSet, // HTTP + gRPC servers
newApp,
)
return nil, nil, nil
}
Running wire ./cmd/order-service/ generates wire_gen.go:
// cmd/order-service/wire_gen.go — AUTO-GENERATED, DO NOT EDIT
func initApp(logger log.Logger, conf *conf.Bootstrap) (*kratos.App, func(), error) {
db, cleanup, err := data.NewDatabase(conf.Data)
if err != nil { return nil, nil, err }
redisClient := data.NewRedisClient(conf.Data)
orderRepo := data.NewOrderRepository(db, redisClient)
orderUseCase := biz.NewOrderUseCase(orderRepo)
orderService := service.NewOrderService(orderUseCase)
grpcServer := server.NewGRPCServer(conf.Server, orderService, logger)
httpServer := server.NewHTTPServer(conf.Server, orderService, logger)
app, cleanup2, err := newApp(logger, grpcServer, httpServer)
// ...
return app, func() { cleanup2(); cleanup() }, nil
}
No reflection. No runtime DI container. The initialization order is determined at compile time, and Go’s type system guarantees every dependency is satisfied. If you add a new dependency to OrderUseCase but forget to provide it in ProviderSet, go build fails.
3. Dual Transport: HTTP + gRPC on Separate Ports
Every service exposes two ports simultaneously:
// internal/server/grpc.go
func NewGRPCServer(c *conf.Server, os *service.OrderService, logger log.Logger) *grpc.Server {
srv := grpc.NewServer(
grpc.Address(c.Grpc.Addr), // :9001
grpc.Timeout(c.Grpc.Timeout.AsDuration()),
grpc.Middleware(
recovery.Recovery(),
tracing.Server(),
logging.Server(logger),
validate.Validator(), // proto-level field validation
),
)
orderv1.RegisterOrderServiceServer(srv, os)
return srv
}
// internal/server/http.go
func NewHTTPServer(c *conf.Server, os *service.OrderService, logger log.Logger) *http.Server {
srv := http.NewServer(
http.Address(c.Http.Addr), // :8001
http.Timeout(c.Http.Timeout.AsDuration()),
http.Middleware(
recovery.Recovery(),
tracing.Server(),
logging.Server(logger),
jwt.Server(keyFunc), // JWT validation on HTTP only
),
)
orderv1.RegisterOrderServiceHTTPServer(srv, os)
return srv
}
The proto annotations handle HTTP route generation automatically:
// cmd/order-service/main.go
func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server) (*kratos.App, func(), error) {
app := kratos.New(
kratos.Name("order-service"),
kratos.Version("v1.0.0"),
kratos.Logger(logger),
kratos.Server(gs, hs), // Both servers registered
)
return app, app.Stop, nil
}
Port convention (consistent across all 21 services):
- HTTP:
8xxx(e.g., Order = 8001, Payment = 8003, Catalog = 8005) - gRPC:
9xxx(e.g., Order = 9001, Payment = 9003, Catalog = 9005)
This is not arbitrary — it’s discoverable. Any engineer who knows a service’s HTTP port immediately knows its gRPC port by adding 1000.
4. The biz Layer: Where Business Logic Lives
This is where the real domain work happens. In Magento terms, biz is the combination of your Service Layer (service contracts), your Models (domain entities), and your business rule enforcement — but strictly isolated from any I/O.
// internal/biz/order.go
// Domain entity — no ORM annotations, no proto types
type Order struct {
ID string
CustomerID string
Status OrderStatus
Items []OrderItem
Total money.Money
CreatedAt time.Time
UpdatedAt time.Time
}
type OrderStatus string
const (
OrderStatusPending OrderStatus = "PENDING"
OrderStatusConfirmed OrderStatus = "CONFIRMED"
OrderStatusCancelled OrderStatus = "CANCELLED"
// ... 5 more states
)
// Repository interface — biz defines the contract, data implements it
type OrderRepository interface {
Create(ctx context.Context, order *Order) (*Order, error)
FindByID(ctx context.Context, id string) (*Order, error)
UpdateStatus(ctx context.Context, id string, status OrderStatus) error
// ...
}
// internal/biz/order_usecase.go
type OrderUseCase struct {
repo OrderRepository // Interface — not the PostgreSQL implementation
events events.Publisher // from common library
log *log.Helper
}
func (uc *OrderUseCase) CreateOrder(ctx context.Context, order *Order) (*Order, error) {
// Business rule: cannot create order if customer has unpaid orders > 3
count, err := uc.repo.CountUnpaidByCustomer(ctx, order.CustomerID)
if err != nil { return nil, err }
if count >= 3 {
return nil, errors.BadRequest("TOO_MANY_UNPAID_ORDERS",
"customer %s has %d unpaid orders", order.CustomerID, count)
}
// Create in DB (via repo interface)
created, err := uc.repo.Create(ctx, order)
if err != nil { return nil, err }
// Publish domain event (via common/events)
uc.events.Publish(ctx, "orders.order.created", &events.OrderCreated{
OrderID: created.ID,
CustomerID: created.CustomerID,
Items: created.Items,
})
return created, nil
}
Notice: biz has no import for database/sql, pgxpool, or any PostgreSQL library. It depends only on interfaces. This is why you can test biz with a mock repository — no database required.
5. The common Library: v1.9.5
With 21 services, every pattern you implement in one service will be implemented in all 21. The platform solves this with a shared Go module:
// go.mod of any service
require gitlab.com/ta-microservices/common v1.9.5
ADR-023 documents that common v1.9.5 eliminated 4,150 lines of duplicate boilerplate across 19 services compared to v1.0.0. Here’s what’s in it:
common/
├── cache/ ← Cache-Aside with single-flight stampede protection
├── config/ ← go-kratos Config utilities
├── database/ ← PostgreSQL connection, Goose migration helper
├── events/ ← Dapr event publishing + outbox integration
├── middleware/ ← Auth, logging, tracing gRPC/HTTP middleware
├── auth/ ← JWT validation
├── logging/ ← Structured logging with correlation IDs
├── metrics/ ← Prometheus metrics: request count, latency, error rate
├── errors/ ← Standardized error codes
├── validation/ ← Input validation utilities
├── worker/ ← Outbox processor + distributed cron (RedLock)
└── utils/ ← General utilities
Key package: common/cache
import "gitlab.com/ta-microservices/common/cache"
// Cache-Aside with single-flight protection (prevents cache stampede)
product, err := cache.GetOrFetch(ctx, cache.Key("product", productID), func() (*Product, error) {
return repo.FindByID(ctx, productID)
}, cache.TTL(1*time.Hour))
The single-flight primitive ensures that if 1,000 requests arrive simultaneously for the same cache key (expired), only one PostgreSQL query is executed. The other 999 requests wait and receive the result from the first query. Without this, a cache expiry during a flash sale creates a thundering herd that brings down the database.
Key package: common/worker
import "gitlab.com/ta-microservices/common/worker"
// Outbox processor: polls outbox_events table and publishes to Dapr
processor := worker.NewOutboxProcessor(db, daprClient, worker.Config{
PollInterval: 500 * time.Millisecond,
BatchSize: 100,
MaxRetries: 5,
})
processor.Start(ctx)
// Distributed cron (RedLock): runs cron job on exactly one instance
cron := worker.NewDistributedCron(redisClient)
cron.Schedule("0 * * * *", "cleanup-expired-carts", func(ctx context.Context) error {
return cartRepo.DeleteExpired(ctx, 24*time.Hour)
})
6. Kratos Errors: Not Just HTTP Status Codes
Magento’s error handling returns PHP exceptions that get caught and converted to HTTP responses. Kratos’s error system is more precise:
import "github.com/go-kratos/kratos/v2/errors"
// Each error carries: HTTP status code, machine-readable code, human message
errors.NotFound("ORDER_NOT_FOUND", "order %s not found", orderID)
// → HTTP 404, grpc.Code = NOT_FOUND
// → JSON: {"code": 404, "reason": "ORDER_NOT_FOUND", "message": "order xyz not found"}
errors.BadRequest("INVALID_CUSTOMER_ID", "customer_id is required")
// → HTTP 400, grpc.Code = INVALID_ARGUMENT
errors.Forbidden("PERMISSION_DENIED", "requires orders:delete permission")
// → HTTP 403, grpc.Code = PERMISSION_DENIED
errors.InternalServer("DB_ERROR", "database unavailable")
// → HTTP 500, grpc.Code = INTERNAL
The reason field (ORDER_NOT_FOUND) is the machine-readable code. Frontend code pattern-matches on reason for specific error handling (e.g., redirect to cart if CART_EXPIRED), while the message is displayed to users.
7. Configuration: go-kratos Config
Each service reads configuration from:
configs/config.yaml(base config, committed to repo)- Environment variables (Kubernetes ConfigMaps / Secrets)
- Dapr Configuration store (for runtime feature flags)
# configs/config.yaml
server:
http:
addr: ":8001"
timeout: 30s
grpc:
addr: ":9001"
timeout: 10s
data:
database:
source: "postgres://order_svc:${DB_PASS}@${DB_HOST}:5432/order_db?sslmode=require"
max_open_conns: 20
max_idle_conns: 10
redis:
addr: "${REDIS_ADDR}"
db: 1 # Each service uses a different Redis DB number
// cmd/order-service/main.go
func main() {
c := config.New(
config.WithSource(
file.NewSource("configs/config.yaml"),
env.NewSource("APP_"), // APP_DB_PASS, APP_REDIS_ADDR etc.
),
)
var bc conf.Bootstrap
c.Scan(&bc)
// ...
}
What Magento Engineers Need to Unlearn
Coming from Magento, four mental models need to change:
| Magento | Kratos v2 |
|---|---|
DI container configured in di.xml | Wire generates DI code at compile time |
| Controllers handle HTTP + business logic | service/ handles HTTP mapping; biz/ handles logic — never mixed |
| Models can access database directly | biz/ entities have no database imports — only data/ does |
| Runtime plugin system (Plugins/Interceptors) | No runtime monkey-patching; composition happens at Wire injection time |
The Kratos structure is more rigid than Magento’s flexibility — and that’s the point. Rigidity means predictability. Every new engineer who joins the team knows exactly where to look for business logic (internal/biz/), database queries (internal/data/), and API mapping (internal/service/). No hunting through Model/ResourceModel/, Block/, Helper/, and Plugin/ directories.
What’s Next
With the service internals understood, we can look at the API layer: how services communicate with each other via gRPC, and how the Gateway Service exposes REST to external clients. Part 4: gRPC Internal + REST Gateway Architecture covers protobuf conventions, the Money type, cursor pagination, and the complete API lifecycle from .proto file to HTTP response.
FAQ
go-kratos vs Gin — which is faster?
Gin is marginally faster for pure HTTP throughput in benchmarks (~15–20% lower latency in synthetic tests). For real-world microservices, the difference is negligible — the database and network dominate latency, not the framework overhead. The decisive advantage of go-kratos over Gin is dual transport: Kratos handles HTTP and gRPC from the same proto definition. Gin requires you to write separate HTTP handlers and gRPC server code — effectively doubling the boilerplate for each endpoint.
How does Wire dependency injection compare to runtime DI in Spring or Magento?
Wire generates Go code at compile time — there is no reflection, no XML configuration, and no runtime DI container. If a dependency is missing (e.g., you add a new constructor parameter but forget to update the provider set), go build fails. In Spring or Magento, the same mistake produces a runtime exception — often in production. Wire’s compile-time guarantee is the primary reason go-kratos chose it over alternatives like dig (runtime DI).
Can I use a single kratos service for both REST and gRPC without running two separate server processes?
Yes — that is exactly what kratos.Server(gs, hs) does. Both the gRPC server (:9001) and HTTP server (:8001) run as goroutines within the same process. They share the same biz layer and the same database connection pool. The proto annotations (google.api.http) handle HTTP↔gRPC routing automatically so you write the handler logic once.