Implementering av CQRS i Go: En praktisk guide till skalbar arkitektur

Bygg CQRS i Go utan onödigt krångel

Sidinnehåll

CQRS är ett av de mönster som ofta överförsäljs, överkomplieras och ibland felaktigt diagnostiseras som botemedel mot den vanliga CRUD-tråkigheten.

Den användbara versionen är mycket enklare: separera koden som ändrar tillstånd från koden som läser tillstånd, och låt sedan varje sida utvecklas för sitt eget ändamål. Martin Fowler beskriver CQRS som att använda en annan modell för att uppdatera information än den som används för att läsa den, samtidigt som han varnar för att det för de flesta system introducerar riskabel komplexitet. Microsoft poängtar samma kärnprincip i mer operativa termer: separera läs- och skrivmodeller så att var och en kan optimeras oberoende.

CQRS i Go — kommandon och frågor som separerade vägar genom en Go-gopher-hubb

Om du arbetar med Go, passar den idén ovanligt bra till språket. Go är bra på explicita gränser, små gränssnitt, tråkiga datatyper och användningsfallsorienterade paket. Det gör grundläggande CQRS i Go mycket mindre teatraliskt än det ofta ser ut på konferenspresentationer. Du behöver inte händelsekällning, Kafka eller tre databaser för att komma igång. Faktum är att både Microsofts vägledning för CQRS och Three Dots Labs Go-exempel visar att en enkel implementering kan dela samma underliggande lagring, med separata kommando- och frågehanterare som läggs till först och mer avancerad infrastruktur introduceras först när problemet faktiskt kräver det.

Vad CQRS faktiskt betyder

I kärnan drar CQRS en hård linje mellan kommandon och frågor. En fråga läser data och bör inte modifiera systemets tillstånd. Ett kommando ändrar tillstånd och bör inte returnera domändata som sitt huvudresultat. Three Dots Labs uttrycker detta i praktiska Go-termer: frågor returnerar data och kommandon gör ändringar, där fel är ett normalt kommandoresultat. Det är den grundläggande rörelsen. Allt annat är valfritt.

En vanlig missuppfattning är att CQRS automatiskt innebär separata databaser, asynkrona projektioner eller händelsekällning. Det stämmer inte. Microsofts mönsterguide behandlar explicit separata datalager som den mer avancerade formen, inte som standard, och Three Dots Labs visar en Go-implementering där frågor läser från samma databas som skrivingar eftersom det räcker för systemet i fråga. Om din artikel bara undervisar i en sak tydligt, låt det vara detta: CQRS är främst ett val av modellering och applikationsstruktur, inte ett obligatoriskt paketavtal för distribuerade system.

Den andra viktiga detaljen är namngivning. Kommandon ska modellera affärsintent, inte lagringsmutationer. Microsofts exempel kontrasterar “Boka hotellrum” med “Sätt ReservationStatus till Reserverad”, och Three Dots Labs rekommenderar namn nära det sätt som domänexperter pratar på, som “ScheduleTraining” eller “CancelTraining” snarare än generiska verb som “Skapa” och “Radera”. I Go betalar sig denna namngivningsdisciplin eftersom kommandonamn ofta blir typnamn, hanterarnamn och paketgränser.

Varför team vänder sig till det

CQRS blir attraktivt när en enda CRUD-modell börjar göra för många jobb dåligt. Microsofts vägledning listar de vanliga presspunkterna: läs- och skrivrepresentationerna av samma data divergerar, samtidiga uppdateringar skapar låskontention, läsprestanda lider under frågekomplexitet och delade entiteter gör säkerhetsregler till en hopplös knut. Med andra ord är problemet inte att CRUD är moraliskt fel. Problemet är att en modell tvingas uppfylla inkompatibla intressen samtidigt.

Det är särskilt vanligt i tekniska produkter. Skrivingar tenderar att bry sig om validering, invarianter, transaktioner och affärsregler. Läsningar tenderar att bry sig om filter, joins, aggregering, cachning, sortering och att servera exakt den form som en sida eller API behöver. CQRS låter skrivsidan förbli strikt och domänorienterad medan lässidan förbli pragmatisk och DTO-orienterad. Microsoft rekommenderar explicit en skrivmodell fokuserad på validering och konsistens, och en läsmodell fokuserad på DTO:er eller projektioner optimerade för presentation och responsivitet.

Det finns också ett team-nivåfördel. Three Dots Labs argumenterar för att att dela upp kommandon och frågor förbättrar avkoppling, gör exekveringsflödet tydligare och accelererar onboarding eftersom utvecklare kan inspektera en liten lista över tillgängliga kommandon och frågor snarare än att jaga logik genom slumpmässiga servicelager. Microsoft noterar liknande att CQRS är särskilt användbart i samarbetsmiljöer där flera användare uppdaterar samma data och kommandon behöver tillräcklig granularitet för att förhindra eller lösa konflikter.

Min något uppfattade åsikt är denna: de flesta team antar CQRS för sent, efter att en “service” redan har förvandlats till en mjukcentrerad monolit. Men många team antar det också för tidigt, främst för att arkitekturdigrammet såg dyrt och därför allvarligt ut. Rätt ögonblick är när läsningar och skrivingar tydligt drar isär i form, hastighet eller regler, inte när din todo-app har aspirationer.

Fördelarna och priset

Grundläggande CQRS har verkliga fördelar även innan du lägger till någon meddelandeöverföring eller separata lager. Det ger dig mindre kommandomodeller, mindre frågemodeller, tydligare användningsfall och mer uppenbara platser att tillämpa tvärsnittsbekymmer som loggning och instrumentering. Three Dots Labs pekar explicit på bättre kodorganisation, avkoppling och enklare modeller som omedelbara vinster, medan Microservices.io framhåller enklare kommando- och frågemodeller samt stöd för denormaliserade, skalbara läsvyer.

När problemet motiverar det, öppnar CQRS också dörren till starkare optimering av lässidan. Microsofts vägledning noterar att separata läsmodeller kan använda DTO:er, projektioner, skrivskyddade repliker eller till och med en helt annan lagringsteknik. Den pekar också på materialiserade vyer som ett sätt att undvika tunga joins och ORM-tunga frågevägar. Om du utvärderar vilken dataåtkomstlager som ska användas på skrivsidan, Jämförelse av Go ORM:er för PostgreSQL täcker kompromisserna mellan GORM, Ent, Bun och sqlc i praktiska termer. Det är där CQRS börjar ge avkastning operationellt, inte bara strukturellt.

Kostnaden är lika verklig. Fowlers varning är fortfarande rätt startpunkt: för de flesta system lägger CQRS till riskabel komplexitet. Microsoft listar ökad komplexitet och eventuell konsistens som kärnöverväganden, medan Microservices.io lägger till potentiell kodduplikation och replikeringstillbakaskjutning i läsvyer. Om du delar lagersystem ärver du också jobbet att hålla dem synkroniserade, vanligtvis genom händelser, utan att förlita dig på en prydlig distribuerad transaktion mellan din databas och broker.

Händelsekällning tar inte bort den kostnaden; den ändrar formen på den. Microsofts CQRS-vegledning säger att händelsekällning kan göra händelselagret till den enda sanningens källa och låta dig bygga upp materialiserade vyer genom att spela upp historik, medan Event Horizon pekar på spårbarhet och revisionsloggning som stora fördelar. Men Microsoft varnar också att vygenerering, uppspelning och händelsehantering lägger till mer designkomplexitet, och föreslår ögonblicksbilder för att minska uppspelningskostnader. Det är varför jag föredrar att förklara händelsekällning som “CQRS plus ett andra svåra beslut”, inte som entrébiljetten.

En användbar tumregel att ha i minnet är att grundläggande CQRS är billigt medan distribuerat CQRS är dyrt, och att sammanblanda de två samtalena är ett av de vanligaste sätten team hamnar med mycket mer komplexitet än problemet någonsin krävde.

En enkel CQRS-implementering i Go

Ett förnuftigt första steg i Go är att behålla en databas och dela upp endast applikationslagret. Kommandon äger affärsregler och persistence. Frågor returnerar läsmodeller formade för anropare. Detta är exakt den typ av grundläggande CQRS som Three Dots Labs rekommenderar innan man vänder sig till asynkrona bussar eller separata läslager.

Börja med kommandon

package blog

import (
	"context"
	"errors"
	"time"
)

type PublishPostCommand struct {
	Title   string
	Slug    string
	BodyMD  string
	Author  string
}

type PostRepository interface {
	NextID(ctx context.Context) (string, error)
	Save(ctx context.Context, post Post) error
}

type Post struct {
	ID          string
	Title       string
	Slug        string
	BodyMD      string
	Author      string
	PublishedAt time.Time
}

type PublishPostHandler struct {
	Repo  PostRepository
	Now   func() time.Time
}

func (h PublishPostHandler) Handle(ctx context.Context, cmd PublishPostCommand) error {
	if cmd.Title == "" || cmd.Slug == "" || cmd.BodyMD == "" {
		return errors.New("title, slug, and body are required")
	}

	id, err := h.Repo.NextID(ctx)
	if err != nil {
		return err
	}

	post := Post{
		ID:          id,
		Title:       cmd.Title,
		Slug:        cmd.Slug,
		BodyMD:      cmd.BodyMD,
		Author:      cmd.Author,
		PublishedAt: h.Now(),
	}

	return h.Repo.Save(ctx, post)
}

Denna hanterare försöker inte servera en sida, forma ett listresponser eller optimera SQL för ett kortnät. Den tillämpar bara intent och persistar en giltig aggregat. Det är kommandosidan som utför ett jobb bra.

Lägg till frågor

package blog

import "context"

type PostView struct {
	ID          string
	Title       string
	Slug        string
	Author      string
	PublishedAt string
	Excerpt     string
}

type LatestPostsQuery struct {
	Limit int
}

type PostReadModel interface {
	Latest(ctx context.Context, limit int) ([]PostView, error)
	BySlug(ctx context.Context, slug string) (PostView, error)
}

type LatestPostsHandler struct {
	ReadModel PostReadModel
}

func (h LatestPostsHandler) Handle(ctx context.Context, q LatestPostsQuery) ([]PostView, error) {
	limit := q.Limit
	if limit <= 0 {
		limit = 10
	}
	return h.ReadModel.Latest(ctx, limit)
}

type GetPostBySlugQuery struct {
	Slug string
}

type GetPostBySlugHandler struct {
	ReadModel PostReadModel
}

func (h GetPostBySlugHandler) Handle(ctx context.Context, q GetPostBySlugQuery) (PostView, error) {
	return h.ReadModel.BySlug(ctx, q.Slug)
}

Notera att lässidan returnerar en PostView, inte skrivmodellen. Det speglar Microsofts rekommendation om att läsmodellen ska optimeras för DTO:er och presentation, medan skrivmodellen är avstämnd för transaktionell integritet och domänregler.

Koppla ihop det som en Go-applikation, inte en skrybld

package app

import "your/module/internal/blog"

type Application struct {
	Commands Commands
	Queries  Queries
}

type Commands struct {
	PublishPost blog.PublishPostHandler
}

type Queries struct {
	LatestPosts   blog.LatestPostsHandler
	GetPostBySlug blog.GetPostBySlugHandler
}

Den formen är inte en tillfällighet. Three Dots Labs använder ett mycket liknande mönster i Wild Workouts: en Application-typ som exponerar Commands och Queries, med konkreta hanterare trådade från separata app/command och app/query-paket. Deras servicekompositionskod importerar dessa paket separat och konstruerar ett enda applikationsobjekt från dem. Det är ett rent, Go-aktigt sätt att göra gränsen uppenbar utan “Framework Drama”. Om din beroendegraf växer komplex när hanterarna multipliceras, Beroendeinjektion i Go täcker Wire, Dig och konstruktorinjektionsmönster som komposera naturligt med denna hanterarbaserade struktur.

Om du senare behöver asynkrona kommandon, krysstjänsthändelser eller en denormaliserad sökindex, kan du lägga till dem från denna baslinje. Three Dots Labs presenterar explicit asynkrona kommandobussar och separata frågedatabaser som senare optimeringar, inte startpunkten.

Go-bibliotek värda att känna till

Go:s CQRS-ekosystem är smalare än .NET:s, vilket ärligt talat är en välsignelse. Du kan överskåda de verkliga alternativen på en eftermiddag och undvika att anta tre abstraktioner du inte behöver.

Watermill

Watermill är det tydligaste moderna valet när du vill ha CQRS plus meddelandeöverföring. Dess CQRS-komponent är en hög nivå API som låter dig arbeta med Go-strukturer snarare än råa meddelanden, och dess byggblock inkluderar en EventBus, EventProcessor, CommandBus och CommandProcessor. Dokumentationen täcker också händelsehanterargrupper för sorterad bearbetning på delade ämnen, ett läsmodellsexempel och skräddarsydd marshaling-metadata. Utanför CQRS-lagret stöder Watermill ett brett utbud av pub/sub-backends inklusive RabbitMQ, Kafka, NATS Jetstream, Redis Streams, Google Cloud Pub/Sub, SQL, HTTP och andra. Pkg.go.dev markerar Watermill som produktionsredo med en stabil offentlig API sedan v1.0.0, och den aktuella publicerade modulversionen är v1.5.2, med GitHub som listar den utgåvan den 13 maj.

commandBus, err := cqrs.NewCommandBusWithConfig(pub, cfg)
eventBus, err := cqrs.NewEventBusWithConfig(pub, cfg)
commandProcessor, err := cqrs.NewCommandProcessorWithConfig(router, cfg)
eventProcessor, err := cqrs.NewEventProcessorWithConfig(router, cfg)

Använd Watermill när kommandon och händelser behöver korsa processgränser, när du vill att försök och omleveranssemantik ska vara förstaklass, eller när du vet att din “enklare” service redan är halvvägs till händelsestyrd verklighet. Nackdelen är att du nu har samtal om broker, ämnen, ordning och idempotens oavsett om du ville det eller inte. Det är inte en brist i Watermill. Det är kostnaden för problemområdet.

Event Horizon

Event Horizon är ett CQRS- och händelsekällningsverktyg för Go. Dess underhållare beskriver det som använt i produktionssystem, men noterar också att API:t inte är slutgiltigt. Verktyget ger aggregat-, kommando- och händelseregistreringshjälpare, officiella händelselagerimplementationer för minnes- och MongoDB-variantter, projektions- och repository-stöd samt exempel som inkluderar en utboks-mönsterbaserad applikation. Utgivningsströmmen är fortfarande aktiv, med GitHub som visar v0.17.0 den 16 juni och tidigare utgåvor som lägger till funktioner som ögonblicksbilder, återanvändbara projektioner, persistent kommandoschemning och utboks-mönstret.

eh.RegisterAggregate(func(id uuid.UUID) eh.Aggregate {
	return &InvoiceAggregate{ID: id}
})

eh.RegisterCommand(func() eh.Command {
	return &CreateInvoiceCommand{}
})

Event Horizon gör mest mening när händelsekällning är själva syftet, inte en valfri framtida extension. Om du vill ha revisionsvänliga strömmar, omspelbar historik, projektioner och en händelselagercentrerad modell, är det ett seriöst alternativ. Om du bara vill ha renare applikationstjänster i en monolit, är det förmodligen mer maskineri än du behöver. Notisen om att “API:t inte är slutgiltigt” betyder också att du bör budgetera för lite mer anpassning över tid än du skulle med Watermill.

Go-MediatR

Go-MediatR är inte ett fullständigt CQRS-ramverk, men det är användbart för in-process CQRS. Dess README beskriver det som en mediator-mönsterimplementation använd med CQRS, med förfrågan/respons-dispatch för kommandon och frågor, händelsedispach för händelser och pipeline-beteenden för tvärsnittsbekymmer. Projektet har också taggade utgåvor, med GitHub som listar v1.4.0 som senaste utgåva och framhåller trådsäker hanterarregistrering och konkurrensrelaterade förbättringar.

resp, err := mediatr.Send[*CreateProductCommand, *CreateProductResponse](ctx, cmd)
post, err := mediatr.Send[*GetPostBySlugQuery, *PostView](ctx, query)

Detta passar bra om du vill ha hanterarbaserade kommandon och frågor, men inte en broker, projektionsmotor eller händelselager. Det är särskilt vänligt för team som kommer från MediatR i .NET. Kompromissen är lika tydlig: du måste fortfarande designa din egen persistence, läsmodelluppdateringsstrategi och ut-process integrationsberättelse. Med andra ord, det ger dig applikationsgränsen, inte hela arkitekturen.

Äldre ramverk och referensmaterial

Det finns äldre Go CQRS-bibliotek som fortfarande är instruktiva, men jag skulle behandla dem som referensmaterial innan jag behandlade dem som standard för nya projekt.

jetbasrawi/go.cqrs beskriver sig själv som en Go CQRS-referensimplementation med exempelapplikationer baserade på Greg Youngs principer. pkg.go.dev visar dock ingen giltig go.mod, ingen taggad version och ingen stabil version, medan GitHub visar inga utgåvor och paketmetadata publicerades för 7,4 år sedan. Det är användbar historik, inte ett starkt signal för en färsk produktionantagande 2026.

andrewwebber/cqrs är liknande: det ger händelsekällning, kommandoutgivande och bearbetning, händelsepublicering och läsmodellgenerering från publicerade händelser, men paketmetadata publicerades också för 7,4 år sedan. Jag skulle absolut läsa det om du vill förstå hur tidigare Go CQRS-bibliotek angrep problemet. Jag skulle vara försiktig med att göra det till grunden för en ny kodbas om du inte är nöjd med att bli deltidsunderhållare av din egen arkitekturstapling.

En praktisk Go-projektstruktur

En typisk Go CQRS-struktur bör göra användningsfall uppenbara, inte begrava dem under generiska abstraktioner. Wild Workouts är en bra referens här. Repositoryt separerar begränsade kontexter under internal, håller kommandon och frågor i distinkta applikationspaket och trådar dem in i en Application-typ som exponerar Commands och Queries. Servicekomposition samlar ihop adapter, hanterare och beroenden explicit. Mönstren som beskrivs här stämmer överens med den bredare vägledningen i Go-projektstruktur: Praktiker & Mönster, som täcker den bredare uppsättningen av layoutbeslut team står inför när Go-kodbaserna växer.

En pragmatisk struktur ser ut så här:

internal/
  blog/
    app/
      app.go
      command/
        publish_post.go
        unpublish_post.go
      query/
        get_post_by_slug.go
        latest_posts.go
    domain/
      post.go
      slug.go
    adapters/
      postgres/
        post_repository.go
        post_read_model.go
    ports/
      http/
        handler.go
    service/
      application.go

Denna struktur har några fördelar.

Först, kommando- och frågehanterare bor nära de användningsfall de implementerar. Det gör det svårare att dölja affärsbeteende i repositoryer eller hanterare namngivna efter transportlager. Three Dots Labs gör detta direkt i Wild Workouts, där app/command och app/query är separata paket och toppnivåns Application grupperar hanterare efter ansvar.

För det andra, domänpaketet kan förbli fokuserat på invarianter och beteende, medan frågesidan är fri att returnera DTO:er och projektioner. Det stämmer överens med Microsofts vägledning för skriv- och läsmodeller och undviker den vanliga CQRS-antimönstret där frågesidan tvingas tillbaka genom domänobjekt bara för ideologisk renhet.

För det tredje, denna struktur skalar från den minsta användbara CQRS till tyngre varianter. Du kan behålla en PostgreSQL-databas och två repository-implementationer idag, och sedan lägga till en sökindex eller händelsestyrd läsprojektion senare utan att behöva skriva om hela applikationsformen. Three Dots Labs beskriver explicit den progressionen från grundläggande CQRS till asynkrona kommandobussar och separata frågelager endast när systemet behöver dem.

När CQRS passar och när det inte gör det

CQRS gör mening när läsningar och skrivingar verkligen är olika problem. Microsoft rekommenderar det för arbetsbelastningar där läs- och skrivmodeller behöver oberoende optimering, där flera användare samarbetar med samma data, och där tydlig separation hjälper med prestanda, skalbarhet och säkerhet. Microservices.io lägger till en annan klassisk passform: denormaliserade, högpresterande vyer byggda från domänhändelser eller materialiserade projektioner. Three Dots Labs pekar också på komplex affärslogik, underhållbarhet och framtida extension mot asynkrona kommandon eller specialiserade läslager som starka skäl att anta det i Go.

I praktiken betyder det ofta system med rika domänregler, dyra läsmodeller, rapportvyer som inte kartläggs smidigt på aggregat, eller mikrotjänster som publicerar händelser och bygger projektioner annars. I dessa sammanhang dyker Saga-mönstret för distribuerade transaktioner ofta upp bredvid CQRS som koordineringsmekanism för flerstegs affärsoperationer som sträcker sig över servicegränser. Det passar också produkter där skrivsidan måste vara strikt och revisionsbar medan lässidan måste vara snabb och formad för UI- eller API-konsumtion. Om du redan pratar om projektioner, repliker eller att bygga upp vyer från händelser, är du förmodligen i CQRS-territorium oavsett om du använder etiketten eller inte.

CQRS gör inte mening när din service är en rakt ut dataeditor. Fowler säger rakt ut att för de flesta system lägger CQRS till riskabel komplexitet, och Three Dots Labs säger att enkla CRUD-tjänster som mottar och returnerar i grunden samma data inte är en bra passform. I deras eget Wild Workouts-exempel använder en enklare användartjänst inte Clean Architecture och CQRS eftersom mönstrarna inte skulle betala sin hyra där.

Det är den del som är värd att säga tydligt i en teknisk blogg: CQRS är inte ett mognadsmeritbadge utan ett medvetet avvägning, och det gör bara mening när du faktiskt behöver det det ger dig. Om din adminpanel skriver rader och läser tillbaka samma rader, separera inte modellen bara för att du kan. Om dina kommandohanterare främst är “sätt fält X på post Y”, har du inte ett CQRS-problem. Du har en normal applikation, och det är helt respekterad mjukvara.

Avslutande tankar

Det bästa sättet att implementera CQRS i Go är att börja med den tråkiga versionen. Dela upp kommando- och frågehanterare. Låt kommandon modellera affärsintent. Låt frågor returnera läsmodeller. Behåll samma databas om det är allt du behöver. Sedan, endast när systemet tvingar din hand, lägg till asynkrona bussar, projektioner, separata lager eller händelsekällning. Den progressionen är konsistent med Fowlers varning om komplexitet, Microsofts stegvisa CQRS-vegledning och de pragmatiska Go-exemplen från Three Dots Labs.

Om du behöver ett bibliotek, är Watermill det starkaste allroundvalet för meddelandestyrt CQRS i Go, Event Horizon är lockande när händelsekällning är tyngdpunkten, och Go-MediatR är en bra lätt touch när du bara behöver in-process kommando- och frågedispatch. Allt annat bör tjäna sin plats mycket noggrant. För en bredare karta över kodstruktur, integration och dataåtkomstmönster i produktions Go-system, är App Architecture-guiden en användbar följeslagare.

Det, i slutändan, är det mest Go-aktiga svaret på CQRS: använd mönstret, inte kostyumen.

Prenumerera

Få nya inlägg om system, infrastruktur och AI-ingenjörskonst.