The Order Repository
What we’re building
Section titled “What we’re building”migrations/order/0001_init.sql (the orders, order_items, and outbox tables) and services/order/internal/repo/orders.go — an OrderRepo whose Create method inserts an order, its line items, and an outbox row describing the event, all inside one pgx.Tx. Then services/order/internal/server/server.go — a real Server implementing all three OrderServiceServer methods, replacing The gRPC Server →‘s placeholder and finally putting its injected Catalog client to work: CreateOrder looks up each item’s current price from Catalog before ever touching the repository.
OrderRepo.Create writes to three tables — orders, order_items, and outbox — and all three writes share a single transaction on purpose. An order, the line items that make it up, and the fact that this specific event happened are one atomic unit of truth: if a crash landed between inserting the order and inserting its outbox row, the order would exist in the database with no record anywhere that order.created should ever be published — every downstream consumer (Payment, in Order Saga → — Module 9) would simply never learn this order exists. One transaction makes that failure mode structurally impossible: either all three writes land, or none do. The Transactional Outbox → is where the reasoning behind writing an event to a database table instead of publishing straight to Kafka gets its own full explanation — this lesson just writes the code that makes the single-transaction guarantee real.
Pricing belongs in the Server layer, not the repository, for the same reason validation did in The Product API →: OrderRepo.Create takes already-priced items ([]PricedItem) and has no idea Catalog even exists. Deciding how to get a price — a live gRPC call, in this course’s case — is an API-layer decision the repository shouldn’t need to know about.
Pros & cons
Section titled “Pros & cons”One pgx.Tx wrapping the order insert, every item insert, and the outbox insert
- Pros: atomic by construction — there is no code path where an order is saved without its items, or saved without the event that will eventually announce it; a single
defer tx.Rollback(ctx)afterBeginmeans any error anywhere in the function — a bad insert, a JSON marshal failure, anything — safely undoes everything already written in this call. - Cons: the transaction now holds row locks across three tables for its whole duration instead of one, which is a longer critical section than a single-table insert; a JSON marshal failure for the outbox payload (unlikely, but possible if a future field isn’t serializable) now fails order creation entirely, coupling the reliability of event serialization to the reliability of placing an order at all.
codes.FailedPrecondition for a product Catalog reports as not found, rather than codes.InvalidArgument
- Pros: the request itself is syntactically valid —
product_idis a well-formed string — the problem is a fact about current system state (no such product exists right now), which is exactly whatcodes.FailedPreconditionmeans in gRPC’s own status-code guidance, distinct from a malformed request. A client can tell the two apart: “you sent something the server can never accept” (InvalidArgument) versus “what you sent might be valid depending on state” (FailedPrecondition). - Cons: the distinction is subtle and inconsistently applied across real-world APIs — a caller genuinely has to know gRPC’s status-code conventions to branch differently on the two, and plenty of clients just treat both as “bad request” and move on regardless.
Set it up
Section titled “Set it up”1. The migration
Section titled “1. The migration”create extension if not exists pgcrypto;create table orders ( id uuid primary key default gen_random_uuid(), customer_id text not null, status text not null default 'pending', total_cents bigint not null, created_at timestamptz not null default now() );create table order_items ( id bigserial primary key, order_id uuid not null references orders(id) on delete cascade, product_id uuid not null, quantity int not null, unit_price_cents bigint not null );create table outbox ( id uuid primary key default gen_random_uuid(), aggregate_id uuid not null, event_type text not null, payload jsonb not null, created_at timestamptz not null default now(), published_at timestamptz );create index on outbox (published_at) where published_at is null;Save this as migrations/order/0001_init.sql and apply it:
migrate -path migrations/order -database "$ORDER_DB_URL" upA few shape decisions worth noting now (the outbox table gets its own dedicated explanation in The Transactional Outbox →):
statusis stored as lowercasetext—'pending','confirmed','cancelled'— not theOrderStatusproto enum. The database doesn’t know protobuf exists;Server’stoProtoStatus(below) is the one place that maps between the two.order_items.order_id references orders(id) on delete cascademeans deleting an order (this course never does, but a future admin tool might) automatically removes its line items — no orphaned rows to clean up by hand.outbox.aggregate_idis the id of whatever row this event is about — every order-related event in this course points back to anorders.id, which is what lets a future consumer (or a debugging query) find “every event for this order” with onewhere aggregate_id = $1.
2. OrderRepo
Section titled “2. OrderRepo”// Package repo is the PostgreSQL-backed store for the Order service.package repo
import ( "context" "encoding/json" "fmt" "time"
"github.com/jackc/pgx/v5/pgxpool")
// Item is the row shape of a single order_items row.type Item struct { ProductID string Quantity int32 UnitPriceCents int64}
// Order is the row shape of the orders table, with its items attached.type Order struct { ID string CustomerID string Status string TotalCents int64 Items []Item CreatedAt time.Time}
// PricedItem is a line item whose unit price has already been looked up// (from the Catalog service) before Create is called.type PricedItem struct { ProductID string Quantity int32 UnitPriceCents int64}
// OrderRepo is the PostgreSQL-backed store for orders.type OrderRepo struct { db *pgxpool.Pool}
// New returns an OrderRepo backed by db.func New(db *pgxpool.Pool) *OrderRepo { return &OrderRepo{db: db}}
type createdEventItem struct { ProductID string `json:"product_id"` Quantity int32 `json:"quantity"` UnitPriceCents int64 `json:"unit_price_cents"`}
type createdEventPayload struct { OrderID string `json:"order_id"` CustomerID string `json:"customer_id"` TotalCents int64 `json:"total_cents"` Items []createdEventItem `json:"items"`}
// Create inserts a new pending order, its line items, and an "order.created"// outbox row in a single transaction — all three commit together or none do.func (r *OrderRepo) Create(ctx context.Context, customerID string, items []PricedItem) (*Order, error) { var total int64 for _, it := range items { total += int64(it.Quantity) * it.UnitPriceCents }
tx, err := r.db.Begin(ctx) if err != nil { return nil, fmt.Errorf("repo: begin create order tx: %w", err) } defer tx.Rollback(ctx)
var o Order err = tx.QueryRow(ctx, ` insert into orders (customer_id, status, total_cents) values ($1, 'pending', $2) returning id, customer_id, status, total_cents, created_at`, customerID, total, ).Scan(&o.ID, &o.CustomerID, &o.Status, &o.TotalCents, &o.CreatedAt) if err != nil { return nil, fmt.Errorf("repo: insert order: %w", err) }
eventItems := make([]createdEventItem, 0, len(items)) for _, it := range items { if _, err := tx.Exec(ctx, ` insert into order_items (order_id, product_id, quantity, unit_price_cents) values ($1, $2, $3, $4)`, o.ID, it.ProductID, it.Quantity, it.UnitPriceCents, ); err != nil { return nil, fmt.Errorf("repo: insert order item: %w", err) } o.Items = append(o.Items, Item{ProductID: it.ProductID, Quantity: it.Quantity, UnitPriceCents: it.UnitPriceCents}) eventItems = append(eventItems, createdEventItem{ProductID: it.ProductID, Quantity: it.Quantity, UnitPriceCents: it.UnitPriceCents}) }
payload, err := json.Marshal(createdEventPayload{ OrderID: o.ID, CustomerID: o.CustomerID, TotalCents: o.TotalCents, Items: eventItems, }) if err != nil { return nil, fmt.Errorf("repo: marshal order.created payload: %w", err) }
if _, err := tx.Exec(ctx, ` insert into outbox (aggregate_id, event_type, payload) values ($1, 'order.created', $2)`, o.ID, payload, ); err != nil { return nil, fmt.Errorf("repo: insert outbox row: %w", err) }
if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("repo: commit create order tx: %w", err) }
return &o, nil}
// Get returns the order with the given id, items included. The returned// error wraps pgx.ErrNoRows (checkable with errors.Is) when no such order// exists.func (r *OrderRepo) Get(ctx context.Context, id string) (*Order, error) { var o Order err := r.db.QueryRow(ctx, ` select id, customer_id, status, total_cents, created_at from orders where id = $1`, id).Scan(&o.ID, &o.CustomerID, &o.Status, &o.TotalCents, &o.CreatedAt) if err != nil { return nil, fmt.Errorf("repo: get order %s: %w", id, err) }
items, err := r.itemsForOrder(ctx, id) if err != nil { return nil, err } o.Items = items
return &o, nil}
// ListByCustomer returns every order placed by customerID, most recent// first, items included.func (r *OrderRepo) ListByCustomer(ctx context.Context, customerID string) ([]Order, error) { rows, err := r.db.Query(ctx, ` select id, customer_id, status, total_cents, created_at from orders where customer_id = $1 order by created_at desc`, customerID) if err != nil { return nil, fmt.Errorf("repo: list orders: %w", err) } defer rows.Close()
var orders []Order for rows.Next() { var o Order if err := rows.Scan(&o.ID, &o.CustomerID, &o.Status, &o.TotalCents, &o.CreatedAt); err != nil { return nil, fmt.Errorf("repo: scan order: %w", err) } orders = append(orders, o) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("repo: iterate orders: %w", err) }
for i := range orders { items, err := r.itemsForOrder(ctx, orders[i].ID) if err != nil { return nil, err } orders[i].Items = items }
return orders, nil}
// itemsForOrder returns every order_items row for orderID, in insertion// order. Shared by Get and ListByCustomer so both fetch items the same way.func (r *OrderRepo) itemsForOrder(ctx context.Context, orderID string) ([]Item, error) { rows, err := r.db.Query(ctx, ` select product_id, quantity, unit_price_cents from order_items where order_id = $1 order by id`, orderID) if err != nil { return nil, fmt.Errorf("repo: list order items: %w", err) } defer rows.Close()
var items []Item for rows.Next() { var it Item if err := rows.Scan(&it.ProductID, &it.Quantity, &it.UnitPriceCents); err != nil { return nil, fmt.Errorf("repo: scan order item: %w", err) } items = append(items, it) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("repo: iterate order items: %w", err) }
return items, nil}
type statusEventPayload struct { OrderID string `json:"order_id"` Status string `json:"status"`}
// UpdateStatus moves an order to status ("confirmed" or "cancelled") and// writes the matching "order.confirmed"/"order.cancelled" outbox row in the// same transaction. The Order saga (Module 9) is what calls this once it// learns the payment result.func (r *OrderRepo) UpdateStatus(ctx context.Context, id, status string) error { eventType := map[string]string{ "confirmed": "order.confirmed", "cancelled": "order.cancelled", }[status] if eventType == "" { return fmt.Errorf("repo: update order status: no outbox event for status %q", status) }
tx, err := r.db.Begin(ctx) if err != nil { return fmt.Errorf("repo: begin update status tx: %w", err) } defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `update orders set status = $1 where id = $2`, status, id) if err != nil { return fmt.Errorf("repo: update order status: %w", err) } if tag.RowsAffected() == 0 { return fmt.Errorf("repo: update order status: no order with id %s", id) }
payload, err := json.Marshal(statusEventPayload{OrderID: id, Status: status}) if err != nil { return fmt.Errorf("repo: marshal %s payload: %w", eventType, err) }
if _, err := tx.Exec(ctx, ` insert into outbox (aggregate_id, event_type, payload) values ($1, $2, $3)`, id, eventType, payload, ); err != nil { return fmt.Errorf("repo: insert outbox row: %w", err) }
return tx.Commit(ctx)}Save this as services/order/internal/repo/orders.go. Details worth calling out beyond Create’s single transaction:
GetandListByCustomerboth call the privateitemsForOrderhelper rather than duplicating the sameselect ... from order_itemsquery twice.ListByCustomerdoes pay for it with an extra query per order (N+1) — acceptable at this course’s scale, and a candidate for a single joined query if this ever became a hot path, exactly the kind of trade-off The Postgres Repository → already called out for a repository this simple.UpdateStatuscheckseventType == ""before opening a transaction at all — an invalid status string (anything other than"confirmed"or"cancelled") is rejected immediately, so the function never begins a transaction it would just have to roll back.tag.RowsAffected() == 0is howUpdateStatusdetects “no such order.”UPDATEdoesn’t returnpgx.ErrNoRowsthe way a zero-rowSELECTdoes — it succeeds with a command tag reporting how many rows it touched, and zero is the signal to treat as not-found here.- Every value in every query is still bound as
$1,$2, … — the same SQL-injection-proof discipline asProductRepo, with zero exceptions for a payload that happens to already be marshaled JSON.
3. The CreateOrder RPC handler
Section titled “3. The CreateOrder RPC handler”// Package server implements orderv1.OrderServiceServer against an// OrderRepo and a Catalog gRPC client.package server
import ( "context" "errors" "time"
catalogv1 "github.com/avetavos/shopmicro/gen/shopmicro/catalog/v1" orderv1 "github.com/avetavos/shopmicro/gen/shopmicro/order/v1" "github.com/avetavos/shopmicro/services/order/internal/repo" "github.com/jackc/pgx/v5" "google.golang.org/grpc/codes" "google.golang.org/grpc/status")
// Server implements orderv1.OrderServiceServer.type Server struct { orderv1.UnimplementedOrderServiceServer repo *repo.OrderRepo catalog catalogv1.CatalogServiceClient}
// New returns a Server backed by orderRepo, pricing items via catalog.func New(orderRepo *repo.OrderRepo, catalog catalogv1.CatalogServiceClient) *Server { return &Server{repo: orderRepo, catalog: catalog}}
// CreateOrder prices every requested item against the Catalog service, then// persists the order.func (s *Server) CreateOrder(ctx context.Context, req *orderv1.CreateOrderRequest) (*orderv1.Order, error) { if req.GetCustomerId() == "" { return nil, status.Error(codes.InvalidArgument, "customer_id is required") } if len(req.GetItems()) == 0 { return nil, status.Error(codes.InvalidArgument, "items must not be empty") }
priced := make([]repo.PricedItem, 0, len(req.GetItems())) for _, item := range req.GetItems() { if item.GetQuantity() <= 0 { return nil, status.Errorf(codes.InvalidArgument, "quantity for product %s must be positive", item.GetProductId()) }
product, err := s.catalog.GetProduct(ctx, &catalogv1.GetProductRequest{Id: item.GetProductId()}) if err != nil { if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound { return nil, status.Errorf(codes.FailedPrecondition, "product %s does not exist", item.GetProductId()) } return nil, status.Errorf(codes.Internal, "price product %s: %v", item.GetProductId(), err) }
priced = append(priced, repo.PricedItem{ ProductID: item.GetProductId(), Quantity: item.GetQuantity(), UnitPriceCents: product.GetPriceCents(), }) }
o, err := s.repo.Create(ctx, req.GetCustomerId(), priced) if err != nil { return nil, status.Errorf(codes.Internal, "create order: %v", err) } return toProto(*o), nil}
// GetOrder returns a single order by id.func (s *Server) GetOrder(ctx context.Context, req *orderv1.GetOrderRequest) (*orderv1.Order, error) { if req.GetId() == "" { return nil, status.Error(codes.InvalidArgument, "id is required") }
o, err := s.repo.Get(ctx, req.GetId()) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, status.Error(codes.NotFound, "order not found") } return nil, status.Errorf(codes.Internal, "get order: %v", err) } return toProto(*o), nil}
// ListOrders returns every order placed by a customer.func (s *Server) ListOrders(ctx context.Context, req *orderv1.ListOrdersRequest) (*orderv1.ListOrdersResponse, error) { if req.GetCustomerId() == "" { return nil, status.Error(codes.InvalidArgument, "customer_id is required") }
orders, err := s.repo.ListByCustomer(ctx, req.GetCustomerId()) if err != nil { return nil, status.Errorf(codes.Internal, "list orders: %v", err) }
resp := &orderv1.ListOrdersResponse{} for _, o := range orders { resp.Orders = append(resp.Orders, toProto(o)) } return resp, nil}
func toProto(o repo.Order) *orderv1.Order { items := make([]*orderv1.OrderItem, 0, len(o.Items)) for _, it := range o.Items { items = append(items, &orderv1.OrderItem{ ProductId: it.ProductID, Quantity: it.Quantity, UnitPriceCents: it.UnitPriceCents, }) } return &orderv1.Order{ Id: o.ID, CustomerId: o.CustomerID, Status: toProtoStatus(o.Status), TotalCents: o.TotalCents, Items: items, CreatedAt: o.CreatedAt.Format(time.RFC3339), }}
func toProtoStatus(s string) orderv1.OrderStatus { switch s { case "pending": return orderv1.OrderStatus_ORDER_STATUS_PENDING case "confirmed": return orderv1.OrderStatus_ORDER_STATUS_CONFIRMED case "cancelled": return orderv1.OrderStatus_ORDER_STATUS_CANCELLED default: return orderv1.OrderStatus_ORDER_STATUS_UNSPECIFIED }}Save this as services/order/internal/server/server.go. A few mapping and validation details worth calling out:
CreateOrderprices items one at a time, in a sequential loop. Eachs.catalog.GetProductcall is its own gRPC round trip, so an order with five distinct items makes five sequential calls before the transaction even begins. That’s a real latency cost worth naming honestly — a production version might price items concurrently or batch them into a single Catalog RPC, but neither exists in this course’s.protocontract, so a sequential loop is what’s genuinely correct today.status.FromError(err)unwraps a gRPC error back into itscodes.Codeand message. This is howCreateOrdertells “Catalog said this product doesn’t exist” (codes.NotFoundfrom Catalog’s ownGetProduct) apart from “the call to Catalog itself failed” (a network error, a timeout) — only the first becomescodes.FailedPrecondition; the second becomescodes.Internal, since it isn’t a fact about the product at all.errors.Is(err, pgx.ErrNoRows), exactly likeProductRepo/Serverin Catalog.OrderRepo.Getwraps whateverScanreturns with%w, soGetOrdercan still unwrap the chain to find the sentinel underneath and translate it intocodes.NotFound.toProto’so.CreatedAt.Format(time.RFC3339)converts the repository’stime.Timeinto the plainstringthe.protocontract declared forOrder.created_at— The Contracts → chose astringfield overgoogle.protobuf.Timestampfor this course, so the mapping is a singleFormatcall rather than atimestamppbconversion.
4. Wiring Server into main.go
Section titled “4. Wiring Server into main.go”// Command order runs the Order gRPC server.package main
import ( "context" "log" "net" "os" "os/signal" "syscall"
catalogv1 "github.com/avetavos/shopmicro/gen/shopmicro/catalog/v1" orderv1 "github.com/avetavos/shopmicro/gen/shopmicro/order/v1" "github.com/avetavos/shopmicro/pkg/config" "github.com/avetavos/shopmicro/pkg/pg" "github.com/avetavos/shopmicro/services/order/internal/repo" "github.com/avetavos/shopmicro/services/order/internal/server" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/reflection")
func main() { ctx := context.Background()
dbURL := config.Get("ORDER_DB_URL", "postgres://shopmicro:shopmicro@localhost:5432/orders?sslmode=disable") grpcAddr := config.Get("ORDER_GRPC_ADDR", ":50052") catalogAddr := config.Get("CATALOG_GRPC_ADDR", ":50051")
pool, err := pg.NewPool(ctx, dbURL) if err != nil { log.Fatalf("order: connect to postgres: %v", err) } defer pool.Close()
catalogConn, err := grpc.NewClient(catalogAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatalf("order: dial catalog at %s: %v", catalogAddr, err) } defer catalogConn.Close() catalogClient := catalogv1.NewCatalogServiceClient(catalogConn)
lis, err := net.Listen("tcp", grpcAddr) if err != nil { log.Fatalf("order: listen on %s: %v", grpcAddr, err) }
orderRepo := repo.New(pool) orderServer := server.New(orderRepo, catalogClient)
grpcServer := grpc.NewServer() orderv1.RegisterOrderServiceServer(grpcServer, orderServer) reflection.Register(grpcServer)
go func() { log.Printf("order: gRPC server listening on %s", grpcAddr) if err := grpcServer.Serve(lis); err != nil { log.Fatalf("order: serve: %v", err) } }()
stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop
log.Println("order: shutting down") grpcServer.GracefulStop()}Save this over services/order/cmd/main.go. The only change from The gRPC Server →‘s version: orderRepo := repo.New(pool) and orderServer := server.New(orderRepo, catalogClient) build the real dependency chain, and RegisterOrderServiceServer registers orderServer instead of &placeholderServer{catalog: catalogClient}. The pool, the listener, the Catalog dial, the reflection registration, the serving goroutine, and the signal handling are all unchanged — none of that was ever about the placeholder.
Verify
Section titled “Verify”Run Catalog, then Order:
go run ./services/catalog/cmdgo run ./services/order/cmdCreate a product in Catalog first, so Order has something real to price:
grpcurl -plaintext -d '{"name":"Coffee Mug","description":"350ml ceramic mug","price_cents":1299,"stock":50}' \ localhost:50051 shopmicro.catalog.v1.CatalogService/CreateProductCopy the id from the response and place an order for two of them:
grpcurl -plaintext -d '{"customer_id":"cust-1","items":[{"product_id":"8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21","quantity":2}]}' \ localhost:50052 shopmicro.order.v1.OrderService/CreateOrder{ "id": "3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90", "customerId": "cust-1", "status": "ORDER_STATUS_PENDING", "totalCents": "2598", "items": [ { "productId": "8f14e45f-ceea-4c9d-b2a5-0c1e3f4a9b21", "quantity": 2, "unitPriceCents": "1299" } ], "createdAt": "2026-07-14T09:12:03Z"}totalCents is 1299 × 2 = 2598 — priced live from Catalog, never supplied by the client. status comes back ORDER_STATUS_PENDING, matching the 'pending' default the migration wrote. Fetch it back by id:
grpcurl -plaintext -d '{"id":"3a7c9e21-1e4d-4b8a-9c6e-2f8b1d5a7c90"}' \ localhost:50052 shopmicro.order.v1.OrderService/GetOrderList every order for that customer:
grpcurl -plaintext -d '{"customer_id":"cust-1"}' \ localhost:50052 shopmicro.order.v1.OrderService/ListOrdersConfirm codes.FailedPrecondition for a product that doesn’t exist:
grpcurl -plaintext -d '{"customer_id":"cust-1","items":[{"product_id":"00000000-0000-0000-0000-000000000000","quantity":1}]}' \ localhost:50052 shopmicro.order.v1.OrderService/CreateOrderERROR: Code: FailedPrecondition Message: product 00000000-0000-0000-0000-000000000000 does not existAnd confirm codes.InvalidArgument for an empty items list:
grpcurl -plaintext -d '{"customer_id":"cust-1","items":[]}' \ localhost:50052 shopmicro.order.v1.OrderService/CreateOrderERROR: Code: InvalidArgument Message: items must not be emptyFinally, confirm the whole module still builds:
go build ./...No output means success.
migrations/order/0001_init.sql creates orders, order_items, and outbox — the last with a partial index on unpublished rows that The Transactional Outbox → explains in full. OrderRepo.Create in services/order/internal/repo/orders.go inserts the order, every line item, and an order.created outbox row inside one pgx.Tx, so all three commit together or none do; Get, ListByCustomer, and UpdateStatus round out the repository, the last of these writing its own order.confirmed/order.cancelled outbox row for Order Saga → (Module 9) to eventually trigger. services/order/internal/server/server.go’s Server implements CreateOrder by pricing every item through the injected Catalog client — codes.FailedPrecondition for a product Catalog reports missing, codes.Internal for any other Catalog failure — before ever calling repo.Create; GetOrder and ListOrders round-trip straight to the repository. grpcurl confirms the whole path end-to-end: create a product in Catalog, place an order in Order priced from that product’s real price_cents, fetch it back, list it, and see both codes.FailedPrecondition and codes.InvalidArgument fire correctly. That’s the Order service placing real, correctly-priced orders. Next, The Transactional Outbox → explains exactly why that outbox insert — and not a direct publish to Kafka — is what makes order.created reliable.