Go

Boas Práticas de CQRS e Event Sourcing em Go: Implementação Prática para Times Ágeis

12 min de leitura

Boas Práticas de CQRS e Event Sourcing em Go: Implementação Prática para Times Ágeis

Entendendo CQRS: O Padrão de Separação de Responsabilidades CQRS significa Command Query Responsibility Segregation — um padrão arquitetural que separa operações de escrita (Commands) de operações de leitura (Queries). A ideia central é simples: um comando altera o estado do sistema, enquanto uma query apenas consulta dados sem causar efeitos colaterais. Esta separação permite que você otimize cada lado independentemente. Na prática, quando você tradiciona um repositório único para ler e escrever dados, você acaba enfrentando problemas como: modelos de dados complexos que tentam ser "tudo para todos", dificuldade em escalar operações de leitura independentemente de escrita, e lógica de negócio espalhada. CQRS resolve isso criando dois caminhos distintos. O lado de escrita (Command Side) processa operações que modificam estado, enquanto o lado de leitura (Query Side) mantém projeções otimizadas para consultas rápidas. Event Sourcing: Armazenando o Histórico de Mudanças Event Sourcing é um padrão complementar ao CQRS onde, em vez de armazenar apenas o estado atual de uma entidade,

<h2>Entendendo CQRS: O Padrão de Separação de Responsabilidades</h2>

<p>CQRS significa Command Query Responsibility Segregation — um padrão arquitetural que separa operações de escrita (Commands) de operações de leitura (Queries). A ideia central é simples: um comando altera o estado do sistema, enquanto uma query apenas consulta dados sem causar efeitos colaterais. Esta separação permite que você otimize cada lado independentemente.</p>

<p>Na prática, quando você tradiciona um repositório único para ler e escrever dados, você acaba enfrentando problemas como: modelos de dados complexos que tentam ser &quot;tudo para todos&quot;, dificuldade em escalar operações de leitura independentemente de escrita, e lógica de negócio espalhada. CQRS resolve isso criando dois caminhos distintos. O lado de escrita (Command Side) processa operações que modificam estado, enquanto o lado de leitura (Query Side) mantém projeções otimizadas para consultas rápidas.</p>

<h2>Event Sourcing: Armazenando o Histórico de Mudanças</h2>

<p>Event Sourcing é um padrão complementar ao CQRS onde, em vez de armazenar apenas o estado atual de uma entidade, você armazena uma sequência imutável de eventos que descrevem tudo o que aconteceu. Pense em um extrato bancário: você não guarda apenas o saldo final, mas todo o histórico de transações que levou até ali.</p>

<p>Cada evento é um fato irrefutável do passado. Uma vez registrado, nunca muda. Você reconstrói o estado atual replicando todos os eventos na ordem em que ocorreram. Isso oferece auditoria completa, a capacidade de recriar qualquer estado anterior e até de corrigir bugs aplicando novos eventos sem perder o histórico. A desvantagem é a complexidade aumentada e a eventual consistency — nem todos terão o mesmo estado no mesmo instante em um sistema distribuído.</p>

<h2>Implementação Prática em Go</h2>

<h3>Estrutura Base e Modelos de Domínio</h3>

<p>Vamos construir um sistema de conta bancária simples usando CQRS e Event Sourcing. Começamos definindo os eventos do domínio:</p>

<pre><code class="language-go">package domain

import (

&quot;time&quot;

)

// Evento base que toda mudança será registrada

type Event interface {

GetAggregateID() string

GetTimestamp() time.Time

GetType() string

}

// Eventos específicos do domínio

type AccountCreated struct {

AggregateID string

Owner string

InitialBalance float64

Timestamp time.Time

}

func (e AccountCreated) GetAggregateID() string { return e.AggregateID }

func (e AccountCreated) GetTimestamp() time.Time { return e.Timestamp }

func (e AccountCreated) GetType() string { return &quot;AccountCreated&quot; }

type MoneyDeposited struct {

AggregateID string

Amount float64

Timestamp time.Time

}

func (e MoneyDeposited) GetAggregateID() string { return e.AggregateID }

func (e MoneyDeposited) GetTimestamp() time.Time { return e.Timestamp }

func (e MoneyDeposited) GetType() string { return &quot;MoneyDeposited&quot; }

type MoneyWithdrawn struct {

AggregateID string

Amount float64

Timestamp time.Time

}

func (e MoneyWithdrawn) GetAggregateID() string { return e.AggregateID }

func (e MoneyWithdrawn) GetTimestamp() time.Time { return e.Timestamp }

func (e MoneyWithdrawn) GetType() string { return &quot;MoneyWithdrawn&quot; }

// Entidade de Agregado (Aggregate Root)

type BankAccount struct {

ID string

Owner string

Balance float64

Version int

UncommittedEvents []Event

}

// Aplicar eventos ao agregado

func (ba *BankAccount) ApplyEvent(event Event) {

switch e := event.(type) {

case AccountCreated:

ba.ID = e.AggregateID

ba.Owner = e.Owner

ba.Balance = e.InitialBalance

case MoneyDeposited:

ba.Balance += e.Amount

case MoneyWithdrawn:

ba.Balance -= e.Amount

}

ba.Version++

}

// Comandos que geram eventos

func (ba *BankAccount) CreateAccount(id, owner string, initial float64) {

ba.ApplyEvent(AccountCreated{

AggregateID: id,

Owner: owner,

InitialBalance: initial,

Timestamp: time.Now(),

})

ba.UncommittedEvents = append(ba.UncommittedEvents, AccountCreated{

AggregateID: id,

Owner: owner,

InitialBalance: initial,

Timestamp: time.Now(),

})

}

func (ba *BankAccount) Deposit(amount float64) error {

if amount &lt;= 0 {

return ErrInvalidAmount

}

event := MoneyDeposited{

AggregateID: ba.ID,

Amount: amount,

Timestamp: time.Now(),

}

ba.ApplyEvent(event)

ba.UncommittedEvents = append(ba.UncommittedEvents, event)

return nil

}

func (ba *BankAccount) Withdraw(amount float64) error {

if amount &lt;= 0 {

return ErrInvalidAmount

}

if ba.Balance &lt; amount {

return ErrInsufficientFunds

}

event := MoneyWithdrawn{

AggregateID: ba.ID,

Amount: amount,

Timestamp: time.Now(),

}

ba.ApplyEvent(event)

ba.UncommittedEvents = append(ba.UncommittedEvents, event)

return nil

}

var (

ErrInvalidAmount = errors.New(&quot;amount must be greater than zero&quot;)

ErrInsufficientFunds = errors.New(&quot;insufficient funds&quot;)

)</code></pre>

<h3>Armazenamento de Eventos (Event Store)</h3>

<p>O Event Store é o coração do Event Sourcing. Ele persiste todos os eventos de forma imutável:</p>

<pre><code class="language-go">package persistence

import (

&quot;encoding/json&quot;

&quot;sync&quot;

&quot;domain&quot;

)

// EventStore armazena e recupera eventos

type EventStore struct {

events map[string][]domain.Event

mu sync.RWMutex

}

func NewEventStore() *EventStore {

return &amp;EventStore{

events: make(map[string][]domain.Event),

}

}

// SaveEvents persiste novos eventos

func (es *EventStore) SaveEvents(aggregateID string, events []domain.Event) error {

es.mu.Lock()

defer es.mu.Unlock()

if _, exists := es.events[aggregateID]; !exists {

es.events[aggregateID] = []domain.Event{}

}

es.events[aggregateID] = append(es.events[aggregateID], events...)

return nil

}

// GetEvents recupera todos os eventos de um agregado

func (es *EventStore) GetEvents(aggregateID string) ([]domain.Event, error) {

es.mu.RLock()

defer es.mu.RUnlock()

events, exists := es.events[aggregateID]

if !exists {

return []domain.Event{}, nil

}

return events, nil

}

// RebuildAggregate reconstrói o estado a partir dos eventos

func (es EventStore) RebuildAggregate(aggregateID string) (domain.BankAccount, error) {

events, err := es.GetEvents(aggregateID)

if err != nil {

return nil, err

}

account := &amp;domain.BankAccount{}

for _, event := range events {

account.ApplyEvent(event)

}

return account, nil

}</code></pre>

<h3>CQRS: Separando Commands e Queries</h3>

<p>Agora implementamos o lado de Commands (escrita) e Queries (leitura):</p>

<pre><code class="language-go">package cqrs

import (

&quot;domain&quot;

&quot;persistence&quot;

)

// CommandBus executa comandos que modificam estado

type CommandBus struct {

eventStore *persistence.EventStore

}

func NewCommandBus(eventStore persistence.EventStore) CommandBus {

return &amp;CommandBus{

eventStore: eventStore,

}

}

// CreateAccountCommand

type CreateAccountCommand struct {

AccountID string

Owner string

InitialBalance float64

}

func (cb *CommandBus) CreateAccount(cmd CreateAccountCommand) error {

account := &amp;domain.BankAccount{}

account.CreateAccount(cmd.AccountID, cmd.Owner, cmd.InitialBalance)

return cb.eventStore.SaveEvents(cmd.AccountID, account.UncommittedEvents)

}

// DepositCommand

type DepositCommand struct {

AccountID string

Amount float64

}

func (cb *CommandBus) Deposit(cmd DepositCommand) error {

account, err := cb.eventStore.RebuildAggregate(cmd.AccountID)

if err != nil {

return err

}

if err := account.Deposit(cmd.Amount); err != nil {

return err

}

return cb.eventStore.SaveEvents(cmd.AccountID, account.UncommittedEvents)

}

// WithdrawCommand

type WithdrawCommand struct {

AccountID string

Amount float64

}

func (cb *CommandBus) Withdraw(cmd WithdrawCommand) error {

account, err := cb.eventStore.RebuildAggregate(cmd.AccountID)

if err != nil {

return err

}

if err := account.Withdraw(cmd.Amount); err != nil {

return err

}

return cb.eventStore.SaveEvents(cmd.AccountID, account.UncommittedEvents)

}

// QueryBus executa consultas otimizadas

type QueryBus struct {

readModel map[string]*AccountReadModel

}

type AccountReadModel struct {

AccountID string

Owner string

Balance float64

}

func NewQueryBus() *QueryBus {

return &amp;QueryBus{

readModel: make(map[string]*AccountReadModel),

}

}

// GetAccountQuery

type GetAccountQuery struct {

AccountID string

}

func (qb QueryBus) GetAccount(query GetAccountQuery) (AccountReadModel, error) {

model, exists := qb.readModel[query.AccountID]

if !exists {

return nil, ErrAccountNotFound

}

return model, nil

}

// UpdateReadModel sincroniza o modelo de leitura com novos eventos

func (qb QueryBus) UpdateReadModel(accountID string, account domain.BankAccount) {

qb.readModel[accountID] = &amp;AccountReadModel{

AccountID: accountID,

Owner: account.Owner,

Balance: account.Balance,

}

}

var ErrAccountNotFound = errors.New(&quot;account not found&quot;)</code></pre>

<h3>Orquestrando Tudo: Application Service</h3>

<p>Um Application Service conecta commands, eventos e a query model:</p>

<pre><code class="language-go">package application

import (

&quot;cqrs&quot;

&quot;persistence&quot;

)

type BankingService struct {

commandBus *cqrs.CommandBus

queryBus *cqrs.QueryBus

eventStore *persistence.EventStore

}

func NewBankingService(eventStore persistence.EventStore) BankingService {

return &amp;BankingService{

commandBus: cqrs.NewCommandBus(eventStore),

queryBus: cqrs.NewQueryBus(),

eventStore: eventStore,

}

}

func (bs *BankingService) CreateAccount(accountID, owner string, initial float64) error {

cmd := cqrs.CreateAccountCommand{

AccountID: accountID,

Owner: owner,

InitialBalance: initial,

}

if err := bs.commandBus.CreateAccount(cmd); err != nil {

return err

}

// Sincroniza a read model

account, _ := bs.eventStore.RebuildAggregate(accountID)

bs.queryBus.UpdateReadModel(accountID, account)

return nil

}

func (bs *BankingService) Deposit(accountID string, amount float64) error {

cmd := cqrs.DepositCommand{

AccountID: accountID,

Amount: amount,

}

if err := bs.commandBus.Deposit(cmd); err != nil {

return err

}

account, _ := bs.eventStore.RebuildAggregate(accountID)

bs.queryBus.UpdateReadModel(accountID, account)

return nil

}

func (bs *BankingService) Withdraw(accountID string, amount float64) error {

cmd := cqrs.WithdrawCommand{

AccountID: accountID,

Amount: amount,

}

if err := bs.commandBus.Withdraw(cmd); err != nil {

return err

}

account, _ := bs.eventStore.RebuildAggregate(accountID)

bs.queryBus.UpdateReadModel(accountID, account)

return nil

}

func (bs BankingService) GetAccountBalance(accountID string) (cqrs.AccountReadModel, error) {

return bs.queryBus.GetAccount(cqrs.GetAccountQuery{AccountID: accountID})

}</code></pre>

<h3>Exemplo de Uso Completo</h3>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;application&quot;

&quot;persistence&quot;

)

func main() {

// Inicializa Event Store

eventStore := persistence.NewEventStore()

// Cria o serviço da aplicação

service := application.NewBankingService(eventStore)

// Executa comandos

err := service.CreateAccount(&quot;ACC001&quot;, &quot;João Silva&quot;, 1000.00)

if err != nil {

fmt.Println(&quot;Erro ao criar conta:&quot;, err)

return

}

err = service.Deposit(&quot;ACC001&quot;, 500.00)

if err != nil {

fmt.Println(&quot;Erro ao depositar:&quot;, err)

return

}

err = service.Withdraw(&quot;ACC001&quot;, 200.00)

if err != nil {

fmt.Println(&quot;Erro ao sacar:&quot;, err)

return

}

// Consulta o estado atual (via Read Model)

account, err := service.GetAccountBalance(&quot;ACC001&quot;)

if err != nil {

fmt.Println(&quot;Erro ao consultar:&quot;, err)

return

}

fmt.Printf(&quot;Conta: %s\n&quot;, account.AccountID)

fmt.Printf(&quot;Titular: %s\n&quot;, account.Owner)

fmt.Printf(&quot;Saldo: R$ %.2f\n&quot;, account.Balance)

// Reconstrói a conta a partir do Event Store (simulando auditar histórico)

rebuilt, _ := eventStore.RebuildAggregate(&quot;ACC001&quot;)

fmt.Printf(&quot;Saldo reconstruído: R$ %.2f\n&quot;, rebuilt.Balance)

}</code></pre>

<h2>Padrões Avançados e Considerações</h2>

<h3>Snapshotting para Performance</h3>

<p>Em sistemas com muitos eventos, replicar todos pode ser lento. Snapshots guardam o estado em pontos específicos:</p>

<pre><code class="language-go">package persistence

type Snapshot struct {

AggregateID string

Version int

State interface{}

}

type EventStoreWithSnapshot struct {

events map[string][]domain.Event

snapshots map[string]Snapshot

mu sync.RWMutex

}

func (es *EventStoreWithSnapshot) RebuildAggregateWithSnapshot(

aggregateID string) (*domain.BankAccount, error) {

es.mu.RLock()

snapshot, hasSnapshot := es.snapshots[aggregateID]

es.mu.RUnlock()

var startVersion int

account := &amp;domain.BankAccount{}

if hasSnapshot {

account = snapshot.State.(*domain.BankAccount)

startVersion = snapshot.Version

}

es.mu.RLock()

events := es.events[aggregateID]

es.mu.RUnlock()

for i, event := range events {

if i &gt;= startVersion {

account.ApplyEvent(event)

}

}

return account, nil

}

func (es *EventStoreWithSnapshot) CreateSnapshot(

aggregateID string, version int, account *domain.BankAccount) {

es.mu.Lock()

defer es.mu.Unlock()

es.snapshots[aggregateID] = Snapshot{

AggregateID: aggregateID,

Version: version,

State: account,

}

}</code></pre>

<h3>Eventual Consistency e Sincronização</h3>

<p>Em ambientes distribuídos, a read model pode estar atrasada. Use message brokers para disseminar eventos:</p>

<pre><code class="language-go">package messaging

type EventPublisher interface {

Publish(event interface{}) error

}

type InMemoryEventBus struct {

subscribers map[string][]func(interface{})

mu sync.RWMutex

}

func NewInMemoryEventBus() *InMemoryEventBus {

return &amp;InMemoryEventBus{

subscribers: make(map[string][]func(interface{})),

}

}

func (eb *InMemoryEventBus) Subscribe(eventType string, handler func(interface{})) {

eb.mu.Lock()

defer eb.mu.Unlock()

eb.subscribers[eventType] = append(eb.subscribers[eventType], handler)

}

func (eb *InMemoryEventBus) Publish(eventType string, event interface{}) {

eb.mu.RLock()

handlers := eb.subscribers[eventType]

eb.mu.RUnlock()

for _, handler := range handlers {

go handler(event)

}

}</code></pre>

<h2>Conclusão</h2>

<p>Três pontos fundamentais que você deve levar deste artigo: Primeiro, <strong>CQRS e Event Sourcing resolvem problemas reais de escalabilidade e auditoria</strong>, separando a lógica de escrita da leitura e permitindo modelos otimizados para cada lado. Segundo, a <strong>implementação prática requer disciplina arquitetural</strong> — o código deve manter agregados puros, eventos imutáveis e uma cadeia clara de responsabilidade. Terceiro, estas técnicas trazem <strong>complexidade operacional</strong> que vale a pena apenas em sistemas que realmente precisam de auditoria completa, alta escala de leitura ou reconstrução de estado histórico; para CRUD simples, você estará sobreenginearing.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing" target="_blank" rel="noopener noreferrer">Event Sourcing Pattern - Microsoft Docs</a></li>

<li><a href="https://martinfowler.com/bliki/CQRS.html" target="_blank" rel="noopener noreferrer">CQRS Pattern - Christopher Meiklejohn</a></li>

<li><a href="https://dave.cheney.net/2016/07/11/loggers-are-fun-and-posts-are-fun" target="_blank" rel="noopener noreferrer">Event Sourcing in Go - Dave Cheney</a></li>

<li><a href="https://vaughnvernon.com/implementing-domain-driven-design/" target="_blank" rel="noopener noreferrer">Implementing Domain-Driven Design - Vaughn Vernon</a></li>

<li><a href="https://go.dev/blog/pipelines" target="_blank" rel="noopener noreferrer">Go Concurrency Patterns - Rob Pike</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Go

O que Todo Dev Deve Saber sobre Type Switch em Go: Discriminando Tipos em Tempo de Execução
O que Todo Dev Deve Saber sobre Type Switch em Go: Discriminando Tipos em Tempo de Execução

O que é Type Switch e Por que Usar Type switch é um mecanismo em Go que permi...

O que Todo Dev Deve Saber sobre Garbage Collector em Go: Funcionamento Interno e Impacto na Performance
O que Todo Dev Deve Saber sobre Garbage Collector em Go: Funcionamento Interno e Impacto na Performance

Introdução: O que é o Garbage Collector em Go? O Garbage Collector (GC) é um...

Dominando Make e New em Go: Diferenças Práticas na Alocação de Memória em Projetos Reais
Dominando Make e New em Go: Diferenças Práticas na Alocação de Memória em Projetos Reais

Make e New em Go: Diferenças Práticas na Alocação de Memória Quando você come...