Example 4: Multi-Deployment with Auto-Router & Proxy
Demonstrates: Convention-based auto-router generation, metadata-driven routing, and seamless local/remote service switching
π About This Example
This example showcases Lokstraβs production-ready patterns for building flexible, deployment-agnostic applications:
Key Features:
- β Auto-router generation from service methods using conventions
- β
Metadata-driven routing via
RegisterServiceTypeoptions - β Single source of truth in service registration
- β
Seamless proxy services with
proxy.Serviceandproxy.CallWithData - β Single binary, multiple deployments (monolith, microservices)
- β YAML-driven configuration with optional metadata overrides
- β Clean Architecture with separated layers (contract, model, service, repository)
Whatβs New vs Manual Approach:
- π No manual handler creation - auto-generated from service methods
- π No manual route definitions - convention-based mapping
- π No manual proxy.Router calls -
proxy.Servicehandles it - π Metadata in RegisterServiceType - clean, centralized configuration
- π Clean separation - contracts, models, services, repositories in separate packages
π― Learning Objectives
- Convention-Based Routing: How services auto-generate RESTful endpoints
- Clean Architecture: Separation of concerns with contract, model, service, repository layers
- Metadata Architecture: Single source of truth in
RegisterServiceType - 2-Level Metadata System: RegisterServiceType β YAML config overrides
- Auto-Proxy Pattern:
proxy.CallWithDatawith convention mapping - Deployment Flexibility: Same code, different runtime behavior
- Interface-Based DI: Depend on contracts, not implementations
π Framework Comparison
Want to see how this same application would be implemented in other frameworks?
π Framework Comparison: Lokstra vs NestJS vs Spring Boot - Same functionality, different approaches with detailed code examples and trade-offs analysis.
ποΈ Architecture
Deployment 1: Monolith (1 Server)
ββββββββββββββββββββββββββββββββββββββββββ
β API Server (Port 3003) β
β β
β ββββββββββββββββββββββββββββββββββββ β
β β UserServiceImpl (local) β β
β β Auto-Router: β β
β β β’ GET /users β β
β β β’ GET /users/{id} β β
β ββββββββββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββ β
β β OrderServiceImpl (local) β β
β β Auto-Router: β β
β β β’ GET /orders/{id} β β
β β β’ GET /users/{user_id}/orders β β
β ββββββββββββββββββββββββββββββββββββ β
β β
β Direct method calls (in-process) β
ββββββββββββββββββββββββββββββββββββββββββ
Deployment 2: Microservices (2 Servers)
βββββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
β User Server β β Order Server β
β (Port 3004) β β (Port 3005) β
β β β β
β βββββββββββββββββββ β HTTP β βββββββββββββββββββββββββ β
β β UserServiceImpl β βββββββββββ€ β UserServiceRemote β β
β β Auto-Router: β β β β (proxy.Service) β β
β β β’ GET /users β β β β β’ CallWithData β β
β β β’ GET /users/{id} β β βββββββββββββββββββββββββ β
β βββββββββββββββββββ β β β β
β β β βββββββββββββββββββββββββ β
βββββββββββββββββββββββββ β β OrderServiceImpl β β
β β Auto-Router: β β
β β β’ GET /orders/{id} β β
β β β’ GET /users/{uid}/ β β
β β orders β β
β βββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββ
Key: UserServiceRemote uses metadata to auto-map methods to HTTP endpoints
π¦ Project Structure
04-multi-deployment/
βββ contract/ # Application Layer - Interfaces & DTOs
β βββ user_contract.go # UserService interface + request/response types
β βββ order_contract.go # OrderService interface + DTOs
β
βββ model/ # Domain Layer - Pure business entities
β βββ user.go # User entity
β βββ order.go # Order entity
β
βββ repository/ # Infrastructure Layer - Data access
β βββ user_repository.go # UserRepository interface + in-memory impl
β βββ order_repository.go # OrderRepository interface + in-memory impl
β
βββ service/ # Application Layer - Business logic
β βββ user_service.go # UserServiceImpl (local implementation)
β βββ user_service_remote.go # UserServiceRemote (HTTP proxy)
β βββ order_service.go # OrderServiceImpl (local implementation)
β βββ order_service_remote.go # OrderServiceRemote (HTTP proxy)
β
βββ config.yaml # Multi-deployment configuration
βββ main.go # Entry point with service registration
βββ test.http # API tests
ποΈ Clean Architecture Layers
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer (service/, contract/) β
β - Business logic & use cases β
β - Service interfaces & DTOs β
β - Depends on: Domain Layer β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Domain Layer (model/) β
β - Pure business entities β
β - No external dependencies β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Infrastructure Layer (repository/) β
β - Data access implementations β
β - External service adapters β
β - Depends on: Domain interfaces β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Dependency Rule: Outer layers depend on inner layers, never the reverse
π Key Concepts
1. Clean Architecture Pattern
This example follows Clean Architecture principles with clear separation of concerns:
Layer 1: Domain (model/)
Pure business entities with no external dependencies:
// model/user.go
package model
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
Characteristics:
- β No framework dependencies
- β No infrastructure dependencies
- β Pure Go structs
- β Represents business concepts
Layer 2: Application (contract/, service/)
contract/user_contract.go - Service interfaces & DTOs:
package contract
import "example/model"
// Service interface (application boundary)
type UserService interface {
GetByID(p *GetUserParams) (*model.User, error)
List(p *ListUsersParams) ([]*model.User, error)
}
// DTOs (Data Transfer Objects)
type GetUserParams struct {
ID int `path:"id"`
}
service/user_service.go - Business logic implementation:
package service
import "example/contract"
import "example/repository"
type UserServiceImpl struct {
UserRepo *service.Cached[repository.UserRepository] // Depend on interface!
}
var _ contract.UserService = (*UserServiceImpl)(nil) // Ensure implementation
func (s *UserServiceImpl) GetByID(p *contract.GetUserParams) (*model.User, error) {
return s.UserRepo.MustGet().GetByID(p.ID)
}
Characteristics:
- β Depends on domain models
- β Depends on repository interfaces (not implementations!)
- β Contains business logic
- β Framework-agnostic (testable!)
Layer 3: Infrastructure (repository/)
repository/user_repository.go - Data access:
package repository
import "example/model"
// Repository interface (defined by application layer needs)
type UserRepository interface {
GetByID(id int) (*model.User, error)
List() ([]*model.User, error)
}
// Implementation (infrastructure detail)
type UserRepositoryMemory struct {
users map[int]*model.User
}
var _ UserRepository = (*UserRepositoryMemory)(nil)
func (r *UserRepositoryMemory) GetByID(id int) (*model.User, error) {
// Implementation details
}
Characteristics:
- β Implements repository interfaces
- β Can be swapped (memory β postgres β redis)
- β Infrastructure details hidden behind interface
Dependency Flow
main.go β service β repository interface
β
model
Dependencies point INWARD (toward domain)
Benefits:
- Testability: Easy to mock repositories and test services
- Flexibility: Swap implementations without changing business logic
- Maintainability: Clear boundaries between layers
- Scalability: Add features without touching existing code
- Team collaboration: Clear ownership per layer
2. Metadata via RegisterServiceType
Services provide routing metadata via RegisterServiceType options:
main.go:
lokstra_registry.RegisterServiceType("user-service-factory",
service.UserServiceFactory,
service.UserServiceRemoteFactory,
deploy.WithResource("user", "users"),
deploy.WithConvention("rest"),
)
lokstra_registry.RegisterServiceType("order-service-factory",
service.OrderServiceFactory,
service.OrderServiceRemoteFactory,
deploy.WithResource("order", "orders"),
deploy.WithConvention("rest"),
deploy.WithRouteOverride("GetByUserID", "GET /users/{user_id}/orders"),
)
Benefits:
- β Single source of truth in registration
- β Used by auto-router generation
- β Clean separation: factories donβt need metadata structs
- β Easy to see all metadata in one place
3. Auto-Router Generation
Framework scans service methods and generates routes using conventions:
// Service method
func (s *UserServiceImpl) GetByID(p *GetUserParams) (*User, error)
// Auto-generates
GET /users/{id} -> UserService.GetByID
Convention mapping:
| Method Name | HTTP Method | Path |
|ββββ-|ββββ-|ββ|
| List() | GET | /users |
| GetByID(params) | GET | /users/{id} |
| Create(params) | POST | /users |
| Update(params) | PUT | /users/{id} |
| Delete(params) | DELETE | /users/{id} |
| Custom actions | POST | /actions/{snake_case} |
4. Custom Route Overrides
Override convention-based routes via RegisterServiceType:
main.go:
lokstra_registry.RegisterServiceType("order-service-factory",
service.OrderServiceFactory,
service.OrderServiceRemoteFactory,
deploy.WithResource("order", "orders"),
deploy.WithConvention("rest"),
deploy.WithRouteOverride("GetByUserID", "GET /users/{user_id}/orders"),
)
Format: "METHOD /path" or just "/path" (defaults to GET)
Result:
GetByID()βGET /orders/{id}(convention)GetByUserID()βGET /users/{user_id}/orders(custom override)
5. Proxy.Service Pattern
Remote services use proxy.CallWithData for type-safe HTTP calls:
func (u *UserServiceRemote) GetByID(params *GetUserParams) (*User, error) {
return proxy.CallWithData[*User](u.GetProxyService(), "GetByID", params)
}
What happens:
- Framework resolves method name to HTTP route using metadata
- Extracts path params from struct tags (
path:"id") - Makes HTTP request
- Auto-extracts data from JSON wrapper (
{"data": {...}}) - Returns typed result
No manual URL construction, no manual JSON parsing!
6. 2-Level Metadata System
Metadata can be provided in 2 places with priority:
Priority 1 (HIGH): YAML config (router-overrides) β Deployment-specific overrides
Priority 2 (MED): RegisterServiceType options β Default metadata
Priority 3 (LOW): Auto-generate from service name β Fallback
Recommended: Put metadata in RegisterServiceType options. Use YAML only for deployment-specific overrides.
π Running the Examples
Option 1: Monolith Deployment
go run . -server "monolith.api-server"
Output:
Starting [api-server] with 2 router(s) on address :3003
[user-auto] GET /users/{id} -> user-auto.GetByID
[user-auto] GET /users -> user-auto.List
[order-auto] GET /orders/{id} -> order-auto.GetByID
[order-auto] GET /users/{user_id}/orders -> order-auto.GetByUserID
All endpoints on port 3003.
Option 2: Microservices Deployment
Terminal 1 - User Server:
go run . -server "microservice.user-server"
Output:
Starting [user-server] with 1 router(s) on address :3004
[user-auto] GET /users/{id} -> user-auto.GetByID
[user-auto] GET /users -> user-auto.List
Terminal 2 - Order Server:
go run . -server "microservice.order-server"
Output:
Starting [order-server] with 1 router(s) on address :3005
[order-auto] GET /orders/{id} -> order-auto.GetByID
[order-auto] GET /users/{user_id}/orders -> order-auto.GetByUserID
π Configuration Walkthrough
config.yaml
service-definitions:
# Infrastructure layer - Repositories
user-repository:
type: user-repository-factory
order-repository:
type: order-repository-factory
# Application layer - Services
user-service:
type: user-service-factory
depends-on: [user-repository]
order-service:
type: order-service-factory
depends-on: [order-repository, user-service] # Can be local OR remote
# Routers auto-generated from published-services using metadata
# Optional overrides commented out - metadata in XXXRemote is enough!
deployments:
monolith:
servers:
api-server:
base-url: "http://localhost"
addr: ":3003"
published-services:
- user-service
- order-service
microservice:
servers:
user-server:
base-url: "http://localhost"
addr: ":3004"
published-services: [user-service]
order-server:
base-url: "http://localhost"
addr: ":3005"
published-services: [order-service]
# Framework auto-detects user-service-remote from topology
Key Points:
- Layered architecture: Repositories (infrastructure) β Services (application)
published-services: Services that expose HTTP endpoints- URLs auto-resolved from topology (which server publishes which service)
- Dependencies auto-loaded from
service-definitions - No manual service listing needed!
main.go
func main() {
// Register repositories (infrastructure layer)
lokstra_registry.RegisterServiceType("user-repository-factory",
repository.NewUserRepositoryMemory, nil)
lokstra_registry.RegisterServiceType("order-repository-factory",
repository.NewOrderRepositoryMemory, nil)
// Register services (application layer) with metadata
lokstra_registry.RegisterServiceType("user-service-factory",
service.UserServiceFactory,
service.UserServiceRemoteFactory,
deploy.WithResource("user", "users"),
deploy.WithConvention("rest"),
)
lokstra_registry.RegisterServiceType("order-service-factory",
service.OrderServiceFactory,
service.OrderServiceRemoteFactory,
deploy.WithResource("order", "orders"),
deploy.WithConvention("rest"),
deploy.WithRouteOverride("GetByUserID", "GET /users/{user_id}/orders"),
)
// Load config - auto-builds ALL deployments
lokstra_registry.LoadAndBuild([]string{"config.yaml"})
// Run server based on flag
lokstra_registry.RunServer(*server, 30*time.Second)
}
Whatβs simplified:
- β No manual router setup
- β No manual handler registration
- β No deployment-specific registration functions
- β No metadata structs in service code
- β Just factory registration with options + LoadAndBuild!
π§ͺ Testing
Use test.http with VS Code REST Client extension:
### Monolith - Get all users
GET http://localhost:3003/users
### Monolith - Get specific user
GET http://localhost:3003/users/1
### Monolith - Get order with user (cross-service)
GET http://localhost:3003/orders/1
### Monolith - Get user's orders
GET http://localhost:3003/users/1/orders
### Microservices - User server
GET http://localhost:3004/users/1
### Microservices - Order server (makes HTTP call to user server)
GET http://localhost:3005/orders/1
GET http://localhost:3005/users/1/orders
π How It Works
Auto-Router Generation Flow
1. Config loading (LoadAndBuild):
ββ Read config.yaml
ββ Find published-services: [user-service, order-service]
ββ Create router definitions: [user-service-router, order-service-router]
ββ Register to global registry
2. Server startup (RunServer):
ββ Get router definitions for current server
ββ Call BuildRouterFromDefinition for each:
β ββ Get service from registry (local or remote auto-resolved)
β ββ Read metadata from RegisterServiceType options
β ββ Merge YAML overrides if any
β ββ Call autogen.NewFromService(svc, rule, override)
β β ββ Scan service methods via reflection
β β ββ Map methods to routes using convention
β β ββ Apply custom overrides from metadata
β β ββ Create auto-generated handlers
β ββ Return router
ββ Mount all routers to app
3. Request handling:
ββ HTTP request arrives
ββ Router matches path to auto-generated handler
ββ Handler calls service method
ββ Service method:
β ββ Monolith: Direct method call (UserServiceImpl)
β ββ Microservices: HTTP proxy call (UserServiceRemote)
β ββ proxy.CallWithData makes HTTP request to remote server
ββ Return response
Cross-Service Call Flow (Microservices)
Client β Order Server β User Server
β
GET /orders/1 OrderServiceImpl.GetByID()
β
s.Users.MustGet().GetByID(...)
β
UserServiceRemote.GetByID()
β
proxy.CallWithData[*User](service, "GetByID", params)
β
[Metadata resolution]
Resource: "user", Plural: "users", Convention: "rest"
Method: "GetByID" β Convention: GET /users/{id}
β
HTTP GET http://localhost:3004/users/1
β
UserServiceImpl.GetByID() @ User Server
β
Return User
π‘ Design Patterns
1. Convention Over Configuration
Instead of:
# β Manual route definitions
routers:
user-router:
routes:
- path: /users
method: GET
handler: listUsers
- path: /users/{id}
method: GET
handler: getUser
We have:
// β
Convention-based auto-generation
type UserService interface {
List() // β GET /users
GetByID() // β GET /users/{id}
}
2. Metadata-Driven Architecture
Single source of truth in service registration:
lokstra_registry.RegisterServiceType("order-service-factory",
service.OrderServiceFactory,
service.OrderServiceRemoteFactory,
deploy.WithResource("order", "orders"),
deploy.WithConvention("rest"),
deploy.WithRouteOverride("GetByUserID", "GET /users/{user_id}/orders"),
)
Used by:
- β Auto-router generation (server-side)
- β Documentation generation (future)
- β API gateway configuration (future)
Benefits:
- β
All metadata visible in one place (
main.go) - β No need to embed metadata in service structs
- β Clean separation of concerns
3. Interface-Based Dependency Injection
type OrderServiceImpl struct {
Users *service.Cached[UserService] // Interface!
}
Runtime resolution:
- Monolith:
UserServiceImpl(local) - Microservices:
UserServiceRemote(proxy)
Same code, different behavior!
4. Zero-Boilerplate Remote Calls
Before (manual):
var wrapper struct {
Data *User `json:"data"`
}
err := proxy.DoJSON("GET", fmt.Sprintf("/users/%d", id), nil, nil, &wrapper)
return wrapper.Data, err
After (auto):
return proxy.CallWithData[*User](service, "GetByID", params)
Framework handles:
- β URL construction
- β Path parameter extraction
- β JSON wrapper unwrapping
- β Error handling
π Advanced Topics
Custom Conventions
Create your own routing conventions:
convention.Register("api-v2", &convention.Definition{
List: "GET /{resource}",
GetByID: "GET /{resource}/{id}",
Create: "POST /{resource}",
// ... custom patterns
})
Deployment-Specific Overrides
Override metadata per environment:
# production.yaml
routers:
order-service-router:
overrides: prod-overrides
router-overrides:
prod-overrides:
path-prefix: /api/v2 # All routes prefixed
hidden: [InternalMethod] # Hide from public
Service Discovery Integration
Auto-resolve service URLs:
external-service-definitions:
user-service:
url: "http://user-service.default.svc.cluster.local"
type: user-service-remote-factory
deployments:
kubernetes:
servers:
order-server:
published-services: [order-service]
# user-service-remote auto-detected from external-service-definitions
π Comparison: Manual vs Auto
| Aspect | Manual Approach | Auto (This Example) |
|---|---|---|
| Router Creation | Manual r.GET() |
Auto-generated |
| Handler Code | Manual functions | Auto-generated |
| Proxy Calls | Manual DoJSON() |
CallWithData() |
| Route Metadata | Hardcoded strings | Convention + metadata |
| Custom Routes | Manual registration | Override in metadata |
| Lines of Code | ~200 lines | ~40 lines |
| Refactoring | Manual updates | Auto-updates |
Code reduction: 80% less boilerplate!
π Production Considerations
1. Service URLs
Development (current):
ProxyService: proxyService // Framework-injected
Production:
external-service-definitions:
user-service-remote:
url: "http://user-service.prod.internal:3004"
timeout: 5s
2. Error Handling
Add circuit breakers, retries:
func (s *OrderServiceImpl) GetByID(p *GetOrderParams) (*OrderWithUser, error) {
user, err := s.Users.MustGet().GetByID(&GetUserParams{ID: order.UserID})
if err != nil {
// Handle remote call failure
if apiErr, ok := err.(*api_client.ApiError); ok {
return nil, fmt.Errorf("user service error: %s", apiErr.Message)
}
return nil, err
}
// ...
}
3. Monitoring
Framework logs auto-router generation:
β¨ Auto-generated router 'user-service-router' from service 'user-service'
β¨ Auto-generated router 'order-service-router' from service 'order-service'
Add custom metrics:
proxy.CallWithData[*User](service, "GetByID", params)
// Framework can track: latency, errors, retries
π― Key Takeaways
Why This Approach Is Better
- Less Code: 80% reduction vs manual approach
- Type Safety: Compile-time checks for method mapping
- Single Source of Truth: Metadata in service code
- Refactoring-Friendly: Rename method β route auto-updates
- Convention-Based: Follow standards (REST, JSON:API, etc.)
- Flexible: Override when needed via metadata or YAML
When to Use Auto-Router
β Good for:
- RESTful APIs
- CRUD operations
- Microservices communication
- Rapid development
- Standard patterns
β Consider manual when:
- Highly custom routing
- Non-standard HTTP patterns
- Need fine-grained control
- Legacy API compatibility
Production Checklist
Before deploying:
- Configure service URLs (env vars or config)
- Add health check endpoints
- Set up monitoring/metrics
- Configure timeouts and retries
- Test failure scenarios
- Document API endpoints
- Set up CI/CD pipelines
π Related Topics
- Framework Comparison - Same app in Lokstra vs NestJS vs Spring Boot
- Lokstra vs NestJS - Detailed TypeScript framework comparison
- Lokstra vs Spring Boot - Detailed Java framework comparison
- 02-framework-guide/auto-router: Deep dive into convention system
- 02-framework-guide/proxy-service: Advanced proxy patterns
- 02-framework-guide/metadata: Metadata architecture
- 02-advanced/custom-conventions: Build your own conventions
- 03-production/service-discovery: Kubernetes, Consul integration
π‘ What You Learned
- β Clean Architecture with contract, model, service, repository layers
- β Auto-router generation from service methods
- β RemoteServiceMeta interface for metadata
- β 3-level metadata system (code β options β YAML)
- β proxy.CallWithData for type-safe HTTP calls
- β Convention-based routing (REST, custom)
- β Single binary, multiple deployments
- β Zero-boilerplate remote service calls
- β Metadata-driven architecture
- β Interface-based dependency injection for testability
Next: Explore custom conventions and advanced routing patterns! π