Estruturando um Monolito Go Para Que Possa Virar Microserviços Depois
"Vamos extrair microserviços depois" é a mentira que contamos para nós mesmos antes de construir um monolito emaranhado que nunca pode ser dividido. O problema não são monolitos—frequentemente são a escolha certa. O problema são monolitos sem fronteiras internas.
Aqui está como estruturar um monolito Go que pode realmente virar microserviços quando (se) você precisar.
O Objetivo: Monolito Modular
Um monolito modular tem fronteiras internas claras que parecem fronteiras de serviço, mas tudo roda em um processo. Você ganha:
- Deploy e operações simples
- Sem latência de rede entre "serviços"
- Refatoração fácil através de fronteiras
- A opção de extrair depois
monolith/
├── cmd/
│ └── server/
│ └── main.go # Ponto de entrada único
├── internal/
│ ├── orders/ # Poderia ser um serviço
│ │ ├── service.go
│ │ ├── repository.go
│ │ ├── handlers.go
│ │ └── events.go
│ ├── payments/ # Poderia ser um serviço
│ │ ├── service.go
│ │ ├── repository.go
│ │ ├── handlers.go
│ │ └── events.go
│ ├── inventory/ # Poderia ser um serviço
│ │ └── ...
│ └── shared/ # Código verdadeiramente compartilhado
│ ├── auth/
│ └── observability/
├── pkg/ # Contratos públicos
│ ├── orderapi/
│ ├── paymentapi/
│ └── events/
└── go.mod
Regra 1: Módulos São Donos de Seus Dados
Cada módulo tem seu próprio schema de banco. Sem acesso a tabelas entre módulos.
// internal/orders/repository.go
type Repository struct {
db *sql.DB
}
// Módulo de orders só toca em tabelas de orders
func (r *Repository) Create(ctx context.Context, order *Order) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO orders.orders (id, customer_id, status, created_at)
VALUES ($1, $2, $3, $4)
`, order.ID, order.CustomerID, order.Status, order.CreatedAt)
return err
}
// ERRADO: Não faça isso - acessando tabelas de outro módulo
func (r *Repository) GetCustomerEmail(ctx context.Context, customerID string) (string, error) {
// Isso cria acoplamento escondido com o módulo de customers
var email string
err := r.db.QueryRowContext(ctx, `
SELECT email FROM customers.customers WHERE id = $1
`, customerID).Scan(&email)
return email, err
}
Em vez disso, defina o que você precisa de outros módulos explicitamente:
// internal/orders/service.go
type CustomerGetter interface {
GetCustomer(ctx context.Context, id string) (*Customer, error)
}
type Service struct {
repo *Repository
customers CustomerGetter // Dependência explícita
payments PaymentProcessor
}
func (s *Service) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// Obtém customer através da interface, não acesso direto ao DB
customer, err := s.customers.GetCustomer(ctx, req.CustomerID)
if err != nil {
return nil, fmt.Errorf("get customer: %w", err)
}
order := &Order{
ID: uuid.NewString(),
CustomerID: customer.ID,
Email: customer.Email, // Desnormaliza o que você precisa
Status: StatusPending,
}
if err := s.repo.Create(ctx, order); err != nil {
return nil, fmt.Errorf("create order: %w", err)
}
return order, nil
}
Isolamento de Schema com PostgreSQL
-- Cada módulo ganha seu próprio schema
CREATE SCHEMA orders;
CREATE SCHEMA payments;
CREATE SCHEMA inventory;
CREATE SCHEMA customers;
-- Tabelas vivem no schema do seu módulo
CREATE TABLE orders.orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL, -- Referência, não FK
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- Sem foreign keys entre schemas!
-- Isso torna extração possível depois
Regra 2: Comunicação Através de Interfaces
Módulos conversam entre si através de interfaces definidas pelo consumidor:
// internal/orders/dependencies.go
package orders
// Define o que orders precisa de outros módulos
type PaymentProcessor interface {
Charge(ctx context.Context, customerID string, amount int) (*PaymentResult, error)
Refund(ctx context.Context, paymentID string) error
}
type InventoryChecker interface {
Reserve(ctx context.Context, items []ReservationRequest) (*Reservation, error)
Release(ctx context.Context, reservationID string) error
}
type CustomerGetter interface {
GetCustomer(ctx context.Context, id string) (*Customer, error)
}
// Tipos que cruzam fronteiras de módulos
type PaymentResult struct {
PaymentID string
Status string
}
type Customer struct {
ID string
Email string
Name string
}
O módulo que implementa satisfaz essas interfaces:
// internal/payments/service.go
package payments
type Service struct {
repo *Repository
gateway PaymentGateway
}
// Satisfaz orders.PaymentProcessor
func (s *Service) Charge(ctx context.Context, customerID string, amount int) (*orders.PaymentResult, error) {
// Implementação
}
Conecte tudo no main:
// cmd/server/main.go
func main() {
// Inicializa módulos
customerService := customers.NewService(customerRepo)
paymentService := payments.NewService(paymentRepo, gateway)
inventoryService := inventory.NewService(inventoryRepo)
// Orders recebe suas dependências injetadas
orderService := orders.NewService(
orderRepo,
customerService, // satisfaz CustomerGetter
paymentService, // satisfaz PaymentProcessor
inventoryService, // satisfaz InventoryChecker
)
}
Regra 3: Eventos para Acoplamento Fraco
Algumas interações não devem ser chamadas síncronas. Use eventos:
// pkg/events/events.go
package events
type OrderCreated struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
Items []Item `json:"items"`
Total int `json:"total"`
CreatedAt time.Time `json:"created_at"`
}
type OrderCompleted struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
CompletedAt time.Time `json:"completed_at"`
}
type PaymentFailed struct {
OrderID string `json:"order_id"`
PaymentID string `json:"payment_id"`
Reason string `json:"reason"`
}
Event bus que funciona in-process agora, pode virar Kafka/NATS depois:
// internal/shared/eventbus/bus.go
package eventbus
type Handler func(ctx context.Context, event any) error
type Bus struct {
mu sync.RWMutex
handlers map[string][]Handler
}
func New() *Bus {
return &Bus{
handlers: make(map[string][]Handler),
}
}
func (b *Bus) Subscribe(eventType string, handler Handler) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[eventType] = append(b.handlers[eventType], handler)
}
func (b *Bus) Publish(ctx context.Context, event any) error {
eventType := reflect.TypeOf(event).String()
b.mu.RLock()
handlers := b.handlers[eventType]
b.mu.RUnlock()
// In-process: roda handlers diretamente
// Depois: serializa e envia para message broker
for _, h := range handlers {
if err := h(ctx, event); err != nil {
// Loga erro, talvez retry, mas não falha o publisher
log.Printf("event handler error: %v", err)
}
}
return nil
}
Uso:
// internal/orders/service.go
func (s *Service) CompleteOrder(ctx context.Context, orderID string) error {
order, err := s.repo.GetByID(ctx, orderID)
if err != nil {
return err
}
order.Status = StatusCompleted
order.CompletedAt = time.Now()
if err := s.repo.Update(ctx, order); err != nil {
return err
}
// Publica evento - outros módulos reagem assincronamente
s.events.Publish(ctx, events.OrderCompleted{
OrderID: order.ID,
CustomerID: order.CustomerID,
CompletedAt: order.CompletedAt,
})
return nil
}
// internal/notifications/handlers.go
func (s *Service) HandleOrderCompleted(ctx context.Context, event any) error {
e := event.(events.OrderCompleted)
return s.SendEmail(ctx, SendEmailRequest{
To: e.CustomerEmail,
Template: "order-completed",
Data: e,
})
}
// Conectado no main.go
eventBus.Subscribe("events.OrderCompleted", notificationService.HandleOrderCompleted)
Regra 4: Contratos de API Pública
Defina a API pública do seu módulo em pkg/:
// pkg/orderapi/api.go
package orderapi
import "context"
// Este é o contrato que outros serviços/módulos usam
type Client interface {
CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error)
GetOrder(ctx context.Context, id string) (*Order, error)
ListOrders(ctx context.Context, customerID string) ([]*Order, error)
}
type CreateOrderRequest struct {
CustomerID string
Items []OrderItem
}
type Order struct {
ID string
CustomerID string
Status string
Items []OrderItem
Total int
CreatedAt time.Time
}
No monolito, a implementação é direta:
// internal/orders/client.go
package orders
// Implementação direta - mesmo processo
type Client struct {
service *Service
}
func NewClient(service *Service) *Client {
return &Client{service: service}
}
func (c *Client) CreateOrder(ctx context.Context, req orderapi.CreateOrderRequest) (*orderapi.Order, error) {
// Chamada direta ao service
order, err := c.service.CreateOrder(ctx, req)
if err != nil {
return nil, err
}
return toAPIOrder(order), nil
}
Quando você extrair para microserviços, troque por um cliente HTTP/gRPC:
// pkg/orderapi/httpclient.go
package orderapi
// Implementação HTTP - processo diferente
type HTTPClient struct {
baseURL string
httpClient *http.Client
}
func NewHTTPClient(baseURL string) *HTTPClient {
return &HTTPClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *HTTPClient) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// Chamada HTTP para orders service
body, _ := json.Marshal(req)
resp, err := c.httpClient.Post(c.baseURL+"/orders", "application/json", bytes.NewReader(body))
// ...
}
O código que chama não muda—ele só usa uma implementação diferente de orderapi.Client.
Regra 5: Separe Modelos de Leitura e Escrita Onde Necessário
Algumas queries precisam de dados de múltiplos módulos. Ao invés de joins entre módulos, construa modelos de leitura:
// internal/reporting/service.go
package reporting
// Modelo de leitura construído a partir de eventos
type OrderSummary struct {
OrderID string
CustomerName string
CustomerEmail string
Items []ItemSummary
Total int
Status string
PaymentStatus string
}
type Service struct {
repo *Repository
}
// Subscreve eventos de múltiplos módulos
func (s *Service) HandleOrderCreated(ctx context.Context, event any) error {
e := event.(events.OrderCreated)
return s.repo.UpsertOrderSummary(ctx, &OrderSummary{
OrderID: e.OrderID,
// ... popula do evento
})
}
func (s *Service) HandlePaymentCompleted(ctx context.Context, event any) error {
e := event.(events.PaymentCompleted)
return s.repo.UpdatePaymentStatus(ctx, e.OrderID, "completed")
}
// Queries consultam o modelo de leitura, não módulos fonte
func (s *Service) GetDashboard(ctx context.Context, customerID string) (*Dashboard, error) {
summaries, err := s.repo.GetOrderSummaries(ctx, customerID)
// Todos os dados são locais - sem queries entre módulos
}
O Playbook de Extração
Quando for hora de extrair um módulo para um serviço:
- Módulo já está isolado - é dono de seus dados, comunica através de interfaces
- Crie handlers HTTP/gRPC para a API pública do módulo
- Faça deploy como serviço separado com seu próprio banco de dados
- Troque a implementação do client no monolito restante
- Event bus vira message broker real (mesmos tipos de evento)
// Antes: in-process
orderClient := orders.NewClient(orderService)
// Depois: HTTP client para orders microservice
orderClient := orderapi.NewHTTPClient("http://orders-service:8080")
// Resto do código inalterado - mesma interface
O Que NÃO Compartilhar
// NÃO compartilhe modelos de domínio entre módulos
// Cada módulo tem seu próprio User, Order, etc.
// internal/orders/models.go
type Customer struct { // Visão de orders de um customer
ID string
Email string
}
// internal/billing/models.go
type Customer struct { // Visão de billing - campos diferentes
ID string
PaymentMethod string
BillingAddress Address
}
// COMPARTILHE:
// - Tipos de evento (pkg/events/)
// - Contratos de API (pkg/orderapi/, pkg/paymentapi/)
// - Utilitários verdadeiramente genéricos (pkg/httputil/, pkg/validate/)
Pontos-Chave
-
Módulos são donos de seus dados. Sem acesso a banco entre módulos. Use interfaces.
-
Defina interfaces no consumidor. Orders define o que precisa de Payments, não o contrário.
-
Eventos para concerns transversais. Notificações, analytics, audit logs—estes subscrevem eventos.
-
API pública em
pkg/. Isso vira seu contrato de serviço quando você extrair. -
Modelos de leitura para queries entre módulos. Construa views desnormalizadas a partir de eventos.
-
Não compartilhe modelos de domínio. Cada módulo tem sua própria visão de conceitos compartilhados.
-
Conecte tudo no main. Injeção de dependência torna trocar implementações trivial.
O objetivo não é construir microserviços em um monolito. É construir um monolito que não lute contra você quando chegar a hora de dividir. A maioria das equipes nunca precisa—e tudo bem. Mas se precisar, você estará pronto.