Build a DDD Taxi Backend with Go
Design a modular monolith with DDD, ConnectRPC, and sqlc in Go.
Introduction
30 Second Summary
Every time you request a ride on your phone, a backend tracks that ride through a series of state changes. The architecture behind those transitions determines whether the system can scale to millions of users or collapse under pressure.
In this project, you will build a taxi ride-request backend in Go, structured as a Domain-Driven Design modular monolith. Every module boundary is an interface, ready to be split into independent microservices without rewriting business logic.
What You'll Build
A Go backend that models the full ride lifecycle using ConnectRPC for the API layer, sqlc for type-safe database queries, and DDD patterns to isolate business logic from infrastructure.
By the end of this project, you'll have:
- A Protobuf-defined API with generated ConnectRPC handlers that simultaneously support JSON/HTTP and gRPC.
- A DDD domain layer with a Ride aggregate root that enforces state transitions, repository interfaces, and value objects.
- A PostgreSQL persistence layer with sqlc-generated type-safe queries and goose-managed migrations.
- Secret Mission: Add an in-memory domain event bus that publishes events for future async processing.
Are there any prerequisites?
This project assumes familiarity with Go, Protocol Buffers, SQL, and Docker Compose. All tools will be installed or verified in Step 1.
Before We Start
Before writing domain logic or API definitions, you need the right tools in place. This project relies on code generation for both the API layer (Buf + ConnectRPC) and the database layer (sqlc).
In this step, you will install and verify those tools, scaffold the project directory structure following Domain-Driven Design conventions, and spin up a local PostgreSQL database.
In this step, get ready to:
- Install and verify Buf CLI and your Go code generation tools.
- Initialize the Go module and create the DDD directory structure.
- Start a local PostgreSQL database with Docker Compose.
Install and verify your tools
This project uses three categories of tools: Buf CLI for Protobuf compilation and linting, Go tool dependencies for code generation plugins, and database utilities (goose for migrations, sqlc for type-safe queries).
- Check if Buf CLI is already installed by running this command:
buf --version
✔️ I see version 1.71.0
You have the correct version of Buf CLI installed. Move on to verifying goose and sqlc below.
ⓧ I see an older version
- Upgrade to the required version by running this command:
go install github.com/bufbuild/buf/cmd/buf@v1.71.0
- Run buf --version again to confirm it now shows 1.71.0.
Still seeing the old version?
Your shell may be caching the old binary path. Close and reopen your terminal, then run buf --version again.
help me fix my Buf CLI version
ⓧ Command not found
- Install Buf CLI by running this command:
go install github.com/bufbuild/buf/cmd/buf@v1.71.0
- Run buf --version to confirm it shows 1.71.0.
Still getting command not found?
Make sure your Go bin directory is in your PATH. Run echo $PATH and check that it includes ~/go/bin (or your custom GOBIN location). Add it to your shell profile if missing.
help me add Go bin to my PATH
- Verify goose is available by running:
goose -version
- You should see a version number in the output.
- Verify sqlc is available by running:
sqlc version
✔️ I see a version number
Both tools are ready. Move on to the next substep.
ⓧ Command not found
- Install the missing tool by running the appropriate command:
# Install goose (if missing)
go install github.com/pressly/goose/v3/cmd/goose@latest
# Install sqlc (if missing)
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
- Run goose -version and sqlc version again to confirm both are now available.
Still getting command not found after installing?
Confirm your Go bin directory (~/go/bin by default) is in your PATH. Restart your terminal after adding it.
help me fix my Go tool PATH
Initialize the Go module and create the project structure
The directory layout follows Domain-Driven Design conventions. Domain logic lives in internal/domain/, application orchestration in internal/application/, and infrastructure adapters (database, HTTP server) in internal/infrastructure/.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir taxi-backend
cd taxi-backend
- Initialize the Go module by running:
go mod init taxi-backend
- You should see a message confirming the go.mod file was created.
- Register the Protobuf code generation plugins as Go tool dependencies by running these commands:
go get -tool google.golang.org/protobuf/cmd/protoc-gen-go@latest
go get -tool connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
What are Go tool dependencies?
Since Go 1.24, go get -tool registers a CLI tool as a project dependency in your go.mod. This means everyone who clones the repo gets the exact same tool version without installing anything globally.
Buf's buf.gen.yaml file invokes these tools with the local: [go, tool, protoc-gen-connect-go] syntax, which runs them from the module's dependency graph.
Seeing a network timeout?
The go get commands download packages from the Go module proxy. If you are behind a corporate firewall or VPN, check your GOPROXY and GONOSUMCHECK settings.
help me fix Go module proxy issues
- Create the DDD directory structure by running this command:
mkdir -p cmd/server proto/ride/v1 internal/domain/ride internal/application/ride internal/infrastructure/postgres internal/infrastructure/server db/migrations db/queries
- Confirm the structure exists by running:
find . -type d | sort
- You should see all the directories listed: cmd/server, proto/ride/v1, internal/domain/ride, internal/application/ride, internal/infrastructure/postgres, internal/infrastructure/server, db/migrations, and db/queries.
Why this directory layout?
This is the ports and adapters (hexagonal) architecture. The internal/domain/ package owns business rules and defines interfaces (ports). The internal/infrastructure/ package provides concrete implementations (adapters) that satisfy those interfaces.
When you later want to split into microservices, you swap an adapter (e.g. replace the in-process function call with a gRPC client) without touching domain logic.
- Create the docker-compose.yml file in the project root by running:
touch docker-compose.yml
- Open docker-compose.yml in your editor and paste the following configuration:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: taxi
POSTGRES_PASSWORD: taxi
POSTGRES_DB: taxi
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
What does this file do?
This Docker Compose file defines a single PostgreSQL 16 container with a user, password, and database all named taxi. Port 5432 is exposed to your host machine so your Go app can connect directly.
The pgdata volume persists data between container restarts. When you run docker compose down -v later, the -v flag removes this volume and resets the database.
- Save docker-compose.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: taxi
POSTGRES_PASSWORD: taxi
POSTGRES_DB: taxi
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Start PostgreSQL and verify connectivity
With the Docker Compose file in place, you can spin up a local PostgreSQL instance using Podman as the container runtime.
- Start the PostgreSQL container in the background by running:
docker compose up -d
- You should see the container being pulled and started. The -d flag runs it in detached mode so your terminal stays free.
- Verify PostgreSQL is accepting connections by running:
pg_isready -h localhost -p 5432 -U taxi
- You should see output containing accepting connections. This confirms the database is ready.
Seeing connection refused?
The container may still be starting up. Wait a few seconds and try pg_isready again. If it persists, check that no other service is using port 5432 with lsof -i :5432.
If docker compose up -d itself failed, make sure Podman's socket is active and that Docker Compose can communicate with it. Run docker compose logs to see container output.
help me troubleshoot Docker Compose with Podman
Your development environment is ready. Next up, you will define the Protobuf API and build the domain model that enforces ride lifecycle transitions.
Define the Proto API and Domain Model
Your development environment is fully set up with Go, Buf, and a running PostgreSQL database. Now it's time to define what your API looks like and how the domain enforces its rules.
In a proto-first workflow, you write your API contract in Protocol Buffers before any implementation code. ConnectRPC generates Go handlers from that contract. Then the domain layer enforces the ride lifecycle as a state machine, completely independent of any database or framework.
In this step, get ready to:
- Write the Protobuf service definition and generate Go code with Buf.
- Implement the Ride aggregate root with state transition methods.
- Define repository interfaces as the domain's ports.
Write the proto service definition and generate code
Before writing any Go implementation, you need to tell Buf where your proto files live and how to generate code from them. Two YAML files handle this configuration.
- Create a buf.yaml file in the root of your taxi-backend folder with this content:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
What does this config do?
- modules tells Buf that your proto files live in the proto/ directory.
- lint enables the STANDARD ruleset, which catches naming issues and missing fields.
- breaking detects breaking changes at the file level, protecting clients from accidental contract changes.
- Save buf.yaml.
You won't see a change yet. This file is used when you run buf generate shortly.
- Create a buf.gen.yaml file in the root of your taxi-backend folder with this content:
version: v2
plugins:
- local: [go, tool, protoc-gen-go]
out: gen
opt: paths=source_relative
- local: [go, tool, protoc-gen-connect-go]
out: gen
opt:
- paths=source_relative
- simple
managed:
enabled: true
override:
- file_option: go_package_prefix
value: taxi-backend/gen
inputs:
- directory: proto
What does this config do?
- plugins lists the two code generators: protoc-gen-go for message structs and protoc-gen-connect-go for ConnectRPC handlers.
- The simple opt generates handler interfaces where methods receive plain proto messages and return (message, error) instead of wrapped types.
- managed mode auto-sets the go_package prefix so you don't have to maintain it manually in every proto file.
- inputs tells Buf to read from the proto/ directory (matching what buf.yaml declares).
- Save buf.gen.yaml.
You won't see a change yet. This file drives code generation in the next command.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure each level uses exactly 2 spaces of indentation, not tabs. The opt field under the connect plugin must be a YAML list (each item on its own line with a - prefix).
help me fix my buf.gen.yaml indentation
Now you'll write the actual API contract. This proto file defines the three RPCs that model the ride lifecycle: request, accept, and complete.
- Create proto/ride/v1/ride.proto with this content:
syntax = "proto3";
package ride.v1;
option go_package = "taxi-backend/gen/ride/v1;ridev1";
message Location {
double latitude = 1;
double longitude = 2;
}
message RequestRideRequest {
string rider_id = 1;
Location pickup = 2;
Location dropoff = 3;
}
message RequestRideResponse {
string ride_id = 1;
string driver_id = 2;
string status = 3;
}
message AcceptRideRequest {
string ride_id = 1;
string driver_id = 2;
}
message AcceptRideResponse {
string ride_id = 1;
string status = 2;
}
message CompleteRideRequest {
string ride_id = 1;
}
message CompleteRideResponse {
string ride_id = 1;
string status = 2;
}
service RideService {
rpc RequestRide(RequestRideRequest) returns (RequestRideResponse) {}
rpc AcceptRide(AcceptRideRequest) returns (AcceptRideResponse) {}
rpc CompleteRide(CompleteRideRequest) returns (CompleteRideResponse) {}
}
What does this proto define?
The RideService declares three RPCs that map to the ride lifecycle: a rider requests, a driver accepts, and the ride completes.
- Location is a reusable message for GPS coordinates (latitude and longitude).
- Each RPC has a dedicated request and response message. This keeps the API explicit and evolvable.
- The go_package option tells protoc-gen-go to place generated code in gen/ride/v1/ with the Go package alias ridev1.
- Save ride.proto.
- Generate the Go stubs and ConnectRPC handlers by running this command:
buf generate
- Confirm the generated files exist by running:
ls gen/ride/v1/
You should see ride.pb.go and a ridev1connect/ directory containing the ConnectRPC handler code.
No gen/ directory appeared?
Make sure you're running buf generate from the project root (the same folder that contains buf.yaml and buf.gen.yaml).
If you see a lint error, check that your proto file uses package ride.v1 exactly as written. Buf's STANDARD rules require the package path to match the directory structure.
help me debug buf generate not producing output
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved: buf.yaml, buf.gen.yaml, and ride.proto.
ⓧ I'd like to double check the full code
Here is buf.yaml:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
Here is buf.gen.yaml:
version: v2
plugins:
- local: [go, tool, protoc-gen-go]
out: gen
opt: paths=source_relative
- local: [go, tool, protoc-gen-connect-go]
out: gen
opt:
- paths=source_relative
- simple
managed:
enabled: true
override:
- file_option: go_package_prefix
value: taxi-backend/gen
inputs:
- directory: proto
Here is proto/ride/v1/ride.proto:
syntax = "proto3";
package ride.v1;
option go_package = "taxi-backend/gen/ride/v1;ridev1";
message Location {
double latitude = 1;
double longitude = 2;
}
message RequestRideRequest {
string rider_id = 1;
Location pickup = 2;
Location dropoff = 3;
}
message RequestRideResponse {
string ride_id = 1;
string driver_id = 2;
string status = 3;
}
message AcceptRideRequest {
string ride_id = 1;
string driver_id = 2;
}
message AcceptRideResponse {
string ride_id = 1;
string status = 2;
}
message CompleteRideRequest {
string ride_id = 1;
}
message CompleteRideResponse {
string ride_id = 1;
string status = 2;
}
service RideService {
rpc RequestRide(RequestRideRequest) returns (RequestRideResponse) {}
rpc AcceptRide(AcceptRideRequest) returns (AcceptRideResponse) {}
rpc CompleteRide(CompleteRideRequest) returns (CompleteRideResponse) {}
}
Implement the Ride aggregate root
In Domain-Driven Design, the aggregate root is the entity that owns and protects the business rules for a group of related objects. For a taxi system, the Ride is the aggregate root. It owns the state transitions that control whether a ride can move from requested to matched to accepted to completed.
- Create internal/domain/ride/ride.go and add the type definitions:
package ride
import (
"errors"
"time"
)
// Status represents the current state of a ride in its lifecycle
type Status string
const (
StatusRequested Status = "REQUESTED"
StatusMatched Status = "MATCHED"
StatusAccepted Status = "ACCEPTED"
StatusCompleted Status = "COMPLETED"
)
// Domain errors for invalid operations
var (
ErrInvalidTransition = errors.New("invalid status transition")
ErrNoDriverAssigned = errors.New("no driver assigned")
)
// Location is a value object representing GPS coordinates
type Location struct {
Latitude float64
Longitude float64
}
// Ride is the aggregate root for the ride-request lifecycle
type Ride struct {
ID string
RiderID string
DriverID string
Pickup Location
Dropoff Location
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
What does this code do?
- Status is a string type with four named constants. This creates a state machine where only valid transitions are allowed.
- Location is a value object. It has no identity of its own. Two locations with the same latitude and longitude are equal.
- The Ride struct holds all ride data. The domain errors (ErrInvalidTransition, ErrNoDriverAssigned) let callers distinguish business rule violations from infrastructure failures.
Now add the constructor and state transition methods below the Ride struct in the same file:
- Add the following code at the bottom of ride.go:
// NewRide creates a ride in the REQUESTED state
func NewRide(riderID string, pickup, dropoff Location) *Ride {
return &Ride{
RiderID: riderID,
Pickup: pickup,
Dropoff: dropoff,
Status: StatusRequested,
}
}
// MatchDriver transitions from REQUESTED to MATCHED
func (r *Ride) MatchDriver(driverID string) error {
if r.Status != StatusRequested {
return ErrInvalidTransition
}
r.DriverID = driverID
r.Status = StatusMatched
return nil
}
// Accept transitions from MATCHED to ACCEPTED
func (r *Ride) Accept(driverID string) error {
if r.Status != StatusMatched {
return ErrInvalidTransition
}
if r.DriverID != driverID {
return ErrNoDriverAssigned
}
r.Status = StatusAccepted
return nil
}
// Complete transitions from ACCEPTED to COMPLETED
func (r *Ride) Complete() error {
if r.Status != StatusAccepted {
return ErrInvalidTransition
}
r.Status = StatusCompleted
return nil
}
Why enforce transitions in the domain?
Each method checks the current status before allowing a transition. A ride can't be completed unless it was accepted first. A ride can't be accepted by a different driver than the one matched.
This is the core DDD principle: business rules live in the domain layer, not scattered across database triggers or HTTP handlers. The rest of the system just calls these methods and trusts the aggregate to protect invariants.
- Save ride.go.
- Verify the domain package compiles by running:
go build ./internal/domain/ride/
You should see no output, which means the package compiled successfully.
Seeing a compilation error?
Check that your package ride declaration is at the top of the file. Make sure the imports include both "errors" and "time".
If you see undeclared name errors, confirm the type definitions (Status, Location, Ride) appear above the functions that reference them in the same file.
help me fix my ride.go compilation error
✔️ Awesome, I've got everything!
Great. Double check you've saved ride.go and it compiles cleanly.
ⓧ I'd like to double check the full code
package ride
import (
"errors"
"time"
)
// Status represents the current state of a ride in its lifecycle
type Status string
const (
StatusRequested Status = "REQUESTED"
StatusMatched Status = "MATCHED"
StatusAccepted Status = "ACCEPTED"
StatusCompleted Status = "COMPLETED"
)
// Domain errors for invalid operations
var (
ErrInvalidTransition = errors.New("invalid status transition")
ErrNoDriverAssigned = errors.New("no driver assigned")
)
// Location is a value object representing GPS coordinates
type Location struct {
Latitude float64
Longitude float64
}
// Ride is the aggregate root for the ride-request lifecycle
type Ride struct {
ID string
RiderID string
DriverID string
Pickup Location
Dropoff Location
Status Status
CreatedAt time.Time
UpdatedAt time.Time
}
// NewRide creates a ride in the REQUESTED state
func NewRide(riderID string, pickup, dropoff Location) *Ride {
return &Ride{
RiderID: riderID,
Pickup: pickup,
Dropoff: dropoff,
Status: StatusRequested,
}
}
// MatchDriver transitions from REQUESTED to MATCHED
func (r *Ride) MatchDriver(driverID string) error {
if r.Status != StatusRequested {
return ErrInvalidTransition
}
r.DriverID = driverID
r.Status = StatusMatched
return nil
}
// Accept transitions from MATCHED to ACCEPTED
func (r *Ride) Accept(driverID string) error {
if r.Status != StatusMatched {
return ErrInvalidTransition
}
if r.DriverID != driverID {
return ErrNoDriverAssigned
}
r.Status = StatusAccepted
return nil
}
// Complete transitions from ACCEPTED to COMPLETED
func (r *Ride) Complete() error {
if r.Status != StatusAccepted {
return ErrInvalidTransition
}
r.Status = StatusCompleted
return nil
}
Define the repository interfaces
In the ports and adapters pattern, the domain declares interfaces (ports) that describe what it needs from infrastructure. The domain never imports a database driver. Instead, it defines contracts that any persistence layer can satisfy.
- Create internal/domain/ride/repository.go with the following interfaces:
package ride
import "context"
// RideRepository defines persistence operations for the Ride aggregate
type RideRepository interface {
Create(ctx context.Context, r *Ride) (*Ride, error)
GetByID(ctx context.Context, id string) (*Ride, error)
UpdateStatus(ctx context.Context, id string, status Status) error
}
// DriverRepository defines operations for finding and managing drivers
type DriverRepository interface {
FindAvailable(ctx context.Context) (string, error)
SetAvailability(ctx context.Context, driverID string, available bool) error
}
Why interfaces in the domain package?
These interfaces are the ports of your architecture. The domain declares what it needs ("save a ride", "find a driver") without knowing how it's done.
In the next step, you'll write PostgreSQL adapters that implement these interfaces. If you ever swap PostgreSQL for another database, only the adapter changes. The domain and application layers stay untouched.
- Save repository.go.
- Verify both domain files compile together by running:
go build ./internal/domain/ride/
No output means both files compiled successfully. The RideRepository interface references the Ride struct and Status type from ride.go, proving the package is internally consistent.
Undefined type errors?
Both files must declare package ride at the top. Go requires all files in the same directory to share the same package name.
If Ride or Status is undefined, make sure ride.go is saved in the same internal/domain/ride/ directory as repository.go.
help me fix undefined type errors in my domain package
✔️ Awesome, I've got everything!
Great. Double check you've saved repository.go and the package compiles cleanly.
ⓧ I'd like to double check the full code
package ride
import "context"
// RideRepository defines persistence operations for the Ride aggregate
type RideRepository interface {
Create(ctx context.Context, r *Ride) (*Ride, error)
GetByID(ctx context.Context, id string) (*Ride, error)
UpdateStatus(ctx context.Context, id string, status Status) error
}
// DriverRepository defines operations for finding and managing drivers
type DriverRepository interface {
FindAvailable(ctx context.Context) (string, error)
SetAvailability(ctx context.Context, driverID string, available bool) error
}
Your API contract is defined, your generated handlers are ready, and your domain layer enforces the ride lifecycle. Next up, you'll write the database migrations and repository adapters that bring persistence to these interfaces.
Set Up the Database and Repository Adapters
Your domain layer defines what a Ride is and how it transitions through states. But those rides need to live somewhere permanent.
In this step, you will create the PostgreSQL schema, generate type-safe query code with sqlc, and implement repository adapters that satisfy the domain interfaces you defined in Step 2.
In this step, get ready to:
- Create and run the database migration with goose.
- Write sqlc query files and generate type-safe Go code.
- Implement repository adapters that connect domain interfaces to the database.
Create and run the database migration
Database migrations are versioned SQL files that define your schema. goose tracks which migrations have been applied so you can evolve your schema safely over time.
- Create the migration directory and file by running these commands:
mkdir -p db/migrations
touch db/migrations/001_initial.sql
- Confirm the file exists by running:
ls db/migrations
You should see 001_initial.sql in the output.
- Open db/migrations/001_initial.sql and paste the following migration:
-- +goose Up
CREATE TABLE drivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
available BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE rides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rider_id TEXT NOT NULL,
driver_id UUID REFERENCES drivers(id),
pickup_lat DOUBLE PRECISION NOT NULL,
pickup_lng DOUBLE PRECISION NOT NULL,
dropoff_lat DOUBLE PRECISION NOT NULL,
dropoff_lng DOUBLE PRECISION NOT NULL,
status TEXT NOT NULL DEFAULT 'REQUESTED',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO drivers (id, name, available) VALUES
('a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'Alice Driver', true),
('b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', 'Bob Driver', true);
-- +goose Down
DROP TABLE rides;
DROP TABLE drivers;
What does this migration do?
The -- +goose Up section creates a drivers table (the pool of available drivers) and a rides table (each ride request with its status). It also inserts two seed drivers so you have data to test with immediately.
The -- +goose Down section reverses the migration. Goose uses these annotations to know which SQL to run in each direction.
- Save db/migrations/001_initial.sql.
- Run the migration against your local PostgreSQL by running this command:
goose -dir db/migrations postgres "postgres://taxi:taxi@localhost:5432/taxi?sslmode=disable" up
You should see output indicating the migration was applied successfully.
Seeing a connection error?
- Make sure your PostgreSQL container is still running. Run docker compose ps to check. If it's stopped, run docker compose up -d to restart it.
- Confirm the connection string matches your docker-compose.yml credentials: user taxi, password taxi, database taxi, port 5432.
Help me troubleshoot my goose migration connection error
Write sqlc queries and generate Go code
With the schema in place, you can now define the SQL queries your application needs. sqlc reads annotated SQL files and generates type-safe Go functions for each query.
- Create sqlc.yaml in your project root with the following configuration:
version: "2"
sql:
- engine: "postgresql"
queries: "db/queries/"
schema: "db/migrations/"
gen:
go:
package: "dbgen"
out: "internal/infrastructure/postgres/dbgen"
sql_package: "pgx/v5"
What does this configuration do?
- It tells sqlc to look for SQL queries in db/queries/ and infer types from the schema in db/migrations/.
- Generated Go code goes into internal/infrastructure/postgres/dbgen as a package named dbgen.
- The sql_package: "pgx/v5" setting generates code that uses pgx types directly instead of the standard database/sql package.
- Create the queries directory by running:
mkdir -p db/queries
- Create db/queries/rides.sql with the ride CRUD queries:
-- name: CreateRide :one
INSERT INTO rides (rider_id, driver_id, pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rider_id, driver_id, pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, status, created_at, updated_at;
-- name: GetRide :one
SELECT id, rider_id, driver_id, pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, status, created_at, updated_at
FROM rides
WHERE id = $1;
-- name: UpdateRideStatus :exec
UPDATE rides SET status = $2, updated_at = now() WHERE id = $1;
What do the sqlc annotations mean?
Each -- name: QueryName :type comment tells sqlc what Go function to generate. The :one type returns a single row. The :exec type runs the query without returning data.
- Create db/queries/drivers.sql with the driver queries:
-- name: FindAvailableDriver :one
SELECT id, name, available, created_at
FROM drivers
WHERE available = true
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- name: SetDriverAvailability :exec
UPDATE drivers SET available = $2 WHERE id = $1;
Why FOR UPDATE SKIP LOCKED?
This prevents two concurrent ride requests from matching the same driver. FOR UPDATE locks the row. SKIP LOCKED means if a driver is already locked by another transaction, the query skips them and picks the next available driver instead of waiting.
- Save all three files (sqlc.yaml, rides.sql, drivers.sql).
- Generate the type-safe Go code by running:
sqlc generate
- Confirm the generated files exist by running:
ls internal/infrastructure/postgres/dbgen/
You should see db.go, models.go, and query files in the output. If you see no output from sqlc generate itself, that means it succeeded.
Seeing a sqlc error?
- Make sure your sqlc.yaml is in the project root (the same directory as go.mod).
- Check that db/queries/ and db/migrations/ paths match your actual directory structure.
- Verify your SQL syntax. A missing comma or typo in column names will cause sqlc to fail with a parse error.
Help me fix my sqlc generate error
Implement the repository adapters
The domain layer defined RideRepository and DriverRepository interfaces as ports. Now you will write the adapters that implement those interfaces using the sqlc-generated code.
- Create internal/infrastructure/postgres/ride_repository.go and paste the following code:
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
domain "taxi-backend/internal/domain/ride"
"taxi-backend/internal/infrastructure/postgres/dbgen"
)
// RideRepository implements domain.RideRepository using sqlc-generated queries.
type RideRepository struct {
queries *dbgen.Queries
}
// NewRideRepository creates a new repository backed by the given database connection.
func NewRideRepository(db dbgen.DBTX) *RideRepository {
return &RideRepository{queries: dbgen.New(db)}
}
// Create persists a new ride and returns it with the database-generated ID.
func (r *RideRepository) Create(ctx context.Context, ride *domain.Ride) (*domain.Ride, error) {
driverID := pgtype.UUID{}
if ride.DriverID != "" {
if err := driverID.Scan(ride.DriverID); err != nil {
return nil, fmt.Errorf("scan driver id: %w", err)
}
}
row, err := r.queries.CreateRide(ctx, dbgen.CreateRideParams{
RiderID: ride.RiderID,
DriverID: driverID,
PickupLat: ride.Pickup.Latitude,
PickupLng: ride.Pickup.Longitude,
DropoffLat: ride.Dropoff.Latitude,
DropoffLng: ride.Dropoff.Longitude,
Status: string(ride.Status),
})
if err != nil {
return nil, fmt.Errorf("create ride: %w", err)
}
return rowToRide(row), nil
}
// GetByID retrieves a ride by its UUID string.
func (r *RideRepository) GetByID(ctx context.Context, id string) (*domain.Ride, error) {
uid := pgtype.UUID{}
if err := uid.Scan(id); err != nil {
return nil, fmt.Errorf("scan id: %w", err)
}
row, err := r.queries.GetRide(ctx, uid)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("ride not found: %s", id)
}
return nil, fmt.Errorf("get ride: %w", err)
}
return rowToRide(dbgen.CreateRideRow(row)), nil
}
// UpdateStatus changes the status of an existing ride.
func (r *RideRepository) UpdateStatus(ctx context.Context, id string, status domain.Status) error {
uid := pgtype.UUID{}
if err := uid.Scan(id); err != nil {
return fmt.Errorf("scan id: %w", err)
}
return r.queries.UpdateRideStatus(ctx, dbgen.UpdateRideStatusParams{
ID: uid,
Status: string(status),
})
}
// rowToRide converts a sqlc-generated row into a domain Ride.
func rowToRide(row dbgen.CreateRideRow) *domain.Ride {
driverID := ""
if row.DriverID.Valid {
driverID = fmt.Sprintf("%x-%x-%x-%x-%x",
row.DriverID.Bytes[0:4], row.DriverID.Bytes[4:6],
row.DriverID.Bytes[6:8], row.DriverID.Bytes[8:10],
row.DriverID.Bytes[10:16])
}
return &domain.Ride{
ID: fmt.Sprintf("%x-%x-%x-%x-%x", row.ID.Bytes[0:4], row.ID.Bytes[4:6], row.ID.Bytes[6:8], row.ID.Bytes[8:10], row.ID.Bytes[10:16]),
RiderID: row.RiderID,
DriverID: driverID,
Pickup: domain.Location{Latitude: row.PickupLat, Longitude: row.PickupLng},
Dropoff: domain.Location{Latitude: row.DropoffLat, Longitude: row.DropoffLng},
Status: domain.Status(row.Status),
}
}
What does this code do?
- RideRepository wraps the sqlc-generated Queries struct and implements the three methods defined by domain.RideRepository.
- The pgtype.UUID conversions bridge between the string UUIDs your domain uses and the binary format pgx expects.
- The rowToRide helper translates database rows back into domain structs, keeping database concerns out of the domain layer.
- Save internal/infrastructure/postgres/ride_repository.go.
- Create internal/infrastructure/postgres/driver_repository.go and paste the following code:
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
domain "taxi-backend/internal/domain/ride"
"taxi-backend/internal/infrastructure/postgres/dbgen"
)
// DriverRepository implements domain.DriverRepository using sqlc-generated queries.
type DriverRepository struct {
queries *dbgen.Queries
}
// NewDriverRepository creates a new driver repository backed by the given database connection.
func NewDriverRepository(db dbgen.DBTX) *DriverRepository {
return &DriverRepository{queries: dbgen.New(db)}
}
// FindAvailable returns the ID of an available driver, locking the row to prevent double-matching.
func (r *DriverRepository) FindAvailable(ctx context.Context) (string, error) {
driver, err := r.queries.FindAvailableDriver(ctx)
if err != nil {
return "", fmt.Errorf("find available driver: %w", err)
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
driver.ID.Bytes[0:4], driver.ID.Bytes[4:6],
driver.ID.Bytes[6:8], driver.ID.Bytes[8:10],
driver.ID.Bytes[10:16]), nil
}
// SetAvailability updates a driver's availability status.
func (r *DriverRepository) SetAvailability(ctx context.Context, driverID string, available bool) error {
uid := pgtype.UUID{}
if err := uid.Scan(driverID); err != nil {
return fmt.Errorf("scan driver id: %w", err)
}
return r.queries.SetDriverAvailability(ctx, dbgen.SetDriverAvailabilityParams{
ID: uid,
Available: available,
})
}
What does this code do?
- DriverRepository implements the two methods from domain.DriverRepository: finding an available driver and toggling their availability.
- Both repositories accept a dbgen.DBTX interface in their constructor. This means you can pass a pgxpool.Pool (connection pool) or a single connection. The adapter doesn't care which.
- Save internal/infrastructure/postgres/driver_repository.go.
- Verify the project compiles by running:
go build ./...
You should see no output, which means the build succeeded without errors.
Seeing import errors?
- Run go mod tidy to fetch missing dependencies like pgx/v5.
- Make sure the sqlc-generated package exists at internal/infrastructure/postgres/dbgen/. If it's empty, re-run sqlc generate.
- Double-check that your import path taxi-backend/internal/infrastructure/postgres/dbgen matches the module name in your go.mod.
Help me fix my Go build errors in the repository adapters
✔️ Awesome, I've got everything!
Great. Make sure you have saved both repository files and that go build ./... passes cleanly.
ⓧ I'd like to double check the full code
Here is the complete ride_repository.go:
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
domain "taxi-backend/internal/domain/ride"
"taxi-backend/internal/infrastructure/postgres/dbgen"
)
type RideRepository struct {
queries *dbgen.Queries
}
func NewRideRepository(db dbgen.DBTX) *RideRepository {
return &RideRepository{queries: dbgen.New(db)}
}
func (r *RideRepository) Create(ctx context.Context, ride *domain.Ride) (*domain.Ride, error) {
driverID := pgtype.UUID{}
if ride.DriverID != "" {
if err := driverID.Scan(ride.DriverID); err != nil {
return nil, fmt.Errorf("scan driver id: %w", err)
}
}
row, err := r.queries.CreateRide(ctx, dbgen.CreateRideParams{
RiderID: ride.RiderID,
DriverID: driverID,
PickupLat: ride.Pickup.Latitude,
PickupLng: ride.Pickup.Longitude,
DropoffLat: ride.Dropoff.Latitude,
DropoffLng: ride.Dropoff.Longitude,
Status: string(ride.Status),
})
if err != nil {
return nil, fmt.Errorf("create ride: %w", err)
}
return rowToRide(row), nil
}
func (r *RideRepository) GetByID(ctx context.Context, id string) (*domain.Ride, error) {
uid := pgtype.UUID{}
if err := uid.Scan(id); err != nil {
return nil, fmt.Errorf("scan id: %w", err)
}
row, err := r.queries.GetRide(ctx, uid)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("ride not found: %s", id)
}
return nil, fmt.Errorf("get ride: %w", err)
}
return rowToRide(dbgen.CreateRideRow(row)), nil
}
func (r *RideRepository) UpdateStatus(ctx context.Context, id string, status domain.Status) error {
uid := pgtype.UUID{}
if err := uid.Scan(id); err != nil {
return fmt.Errorf("scan id: %w", err)
}
return r.queries.UpdateRideStatus(ctx, dbgen.UpdateRideStatusParams{
ID: uid,
Status: string(status),
})
}
func rowToRide(row dbgen.CreateRideRow) *domain.Ride {
driverID := ""
if row.DriverID.Valid {
driverID = fmt.Sprintf("%x-%x-%x-%x-%x",
row.DriverID.Bytes[0:4], row.DriverID.Bytes[4:6],
row.DriverID.Bytes[6:8], row.DriverID.Bytes[8:10],
row.DriverID.Bytes[10:16])
}
return &domain.Ride{
ID: fmt.Sprintf("%x-%x-%x-%x-%x", row.ID.Bytes[0:4], row.ID.Bytes[4:6], row.ID.Bytes[6:8], row.ID.Bytes[8:10], row.ID.Bytes[10:16]),
RiderID: row.RiderID,
DriverID: driverID,
Pickup: domain.Location{Latitude: row.PickupLat, Longitude: row.PickupLng},
Dropoff: domain.Location{Latitude: row.DropoffLat, Longitude: row.DropoffLng},
Status: domain.Status(row.Status),
}
}
And here is the complete driver_repository.go:
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
domain "taxi-backend/internal/domain/ride"
"taxi-backend/internal/infrastructure/postgres/dbgen"
)
type DriverRepository struct {
queries *dbgen.Queries
}
func NewDriverRepository(db dbgen.DBTX) *DriverRepository {
return &DriverRepository{queries: dbgen.New(db)}
}
func (r *DriverRepository) FindAvailable(ctx context.Context) (string, error) {
driver, err := r.queries.FindAvailableDriver(ctx)
if err != nil {
return "", fmt.Errorf("find available driver: %w", err)
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
driver.ID.Bytes[0:4], driver.ID.Bytes[4:6],
driver.ID.Bytes[6:8], driver.ID.Bytes[8:10],
driver.ID.Bytes[10:16]), nil
}
func (r *DriverRepository) SetAvailability(ctx context.Context, driverID string, available bool) error {
uid := pgtype.UUID{}
if err := uid.Scan(driverID); err != nil {
return fmt.Errorf("scan driver id: %w", err)
}
return r.queries.SetDriverAvailability(ctx, dbgen.SetDriverAvailabilityParams{
ID: uid,
Available: available,
})
}
Your persistence layer is complete. The database has its schema, sqlc generates type-safe queries, and your repository adapters satisfy the domain interfaces without leaking any database details upward. Next, you will wire everything together with an application service and ConnectRPC handler to serve real requests.
Wire the Application Service, Handler, and Test End-to-End
You have a domain layer that enforces ride state transitions and repository adapters that persist data. The missing piece is the glue: a thin application service that orchestrates calls between domain and persistence, and an HTTP handler that maps incoming requests to that service.
In this step, you will write the application service, wire a ConnectRPC handler, assemble everything in main.go with constructor-based dependency injection, and prove the full ride lifecycle works with curl and buf curl.
In this step, get ready to:
- Build the application service that orchestrates domain logic and persistence.
- Implement the ConnectRPC handler and wire everything in main.go.
- Test the full ride lifecycle with curl and buf curl.
Build the application service
The application service is a thin orchestrator. It does not own business rules. It fetches data through repository interfaces, delegates state transitions to the domain, and persists results.
- Create the file internal/application/ride/service.go inside your taxi-backend folder by running:
mkdir -p internal/application/ride
- Paste the following code into internal/application/ride/service.go:
package ride
import (
"context"
"fmt"
domain "taxi-backend/internal/domain/ride"
)
// Service orchestrates ride lifecycle operations.
// It delegates business logic to the domain layer and persistence to repositories.
type Service struct {
rides domain.RideRepository
drivers domain.DriverRepository
}
// NewService constructs the application service with its repository dependencies.
func NewService(rides domain.RideRepository, drivers domain.DriverRepository) *Service {
return &Service{rides: rides, drivers: drivers}
}
What does this code do?
The Service struct holds references to the RideRepository and DriverRepository interfaces you defined in the domain layer. Because it depends on interfaces (not concrete types), you can swap the Postgres implementation for a mock or a different database without changing the service.
The constructor NewService accepts the interfaces as arguments. This is constructor-based dependency injection with no framework needed.
- Now add the three lifecycle methods below the constructor in the same file:
// RequestRide finds an available driver, transitions the ride state, and persists it.
func (s *Service) RequestRide(ctx context.Context, riderID string, pickup, dropoff domain.Location) (*domain.Ride, error) {
driverID, err := s.drivers.FindAvailable(ctx)
if err != nil {
return nil, fmt.Errorf("find available driver: %w", err)
}
r := domain.NewRide(riderID, pickup, dropoff)
if err := r.MatchDriver(driverID); err != nil {
return nil, fmt.Errorf("match driver: %w", err)
}
created, err := s.rides.Create(ctx, r)
if err != nil {
return nil, fmt.Errorf("create ride: %w", err)
}
if err := s.drivers.SetAvailability(ctx, driverID, false); err != nil {
return nil, fmt.Errorf("set driver unavailable: %w", err)
}
return created, nil
}
// AcceptRide loads an existing ride, calls the domain Accept method, and updates status.
func (s *Service) AcceptRide(ctx context.Context, rideID, driverID string) (*domain.Ride, error) {
r, err := s.rides.GetByID(ctx, rideID)
if err != nil {
return nil, fmt.Errorf("get ride: %w", err)
}
if err := r.Accept(driverID); err != nil {
return nil, err
}
if err := s.rides.UpdateStatus(ctx, rideID, domain.StatusAccepted); err != nil {
return nil, fmt.Errorf("update status: %w", err)
}
return r, nil
}
// CompleteRide loads an existing ride, calls the domain Complete method,
// updates status, and marks the driver as available again.
func (s *Service) CompleteRide(ctx context.Context, rideID string) (*domain.Ride, error) {
r, err := s.rides.GetByID(ctx, rideID)
if err != nil {
return nil, fmt.Errorf("get ride: %w", err)
}
if err := r.Complete(); err != nil {
return nil, err
}
if err := s.rides.UpdateStatus(ctx, rideID, domain.StatusCompleted); err != nil {
return nil, fmt.Errorf("update status: %w", err)
}
if err := s.drivers.SetAvailability(ctx, r.DriverID, true); err != nil {
return nil, fmt.Errorf("set driver available: %w", err)
}
return r, nil
}
What does this code do?
Each method follows the same pattern: load data through the repository, call a domain method that enforces the state machine, then persist the result. The service never checks status directly. That is the domain's job.
Notice that CompleteRide also releases the driver back to the available pool. This keeps availability consistent without a separate background job.
- Save internal/application/ride/service.go.
Seeing an import error?
Make sure your import path is taxi-backend/internal/domain/ride and that your Go module is named taxi-backend in go.mod. Run go build ./... from the project root to check for compile errors.
help me debug a Go import path mismatch in my application service
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/application/ride/service.go.
ⓧ I'd like to double check the full code
package ride
import (
"context"
"fmt"
domain "taxi-backend/internal/domain/ride"
)
type Service struct {
rides domain.RideRepository
drivers domain.DriverRepository
}
func NewService(rides domain.RideRepository, drivers domain.DriverRepository) *Service {
return &Service{rides: rides, drivers: drivers}
}
func (s *Service) RequestRide(ctx context.Context, riderID string, pickup, dropoff domain.Location) (*domain.Ride, error) {
driverID, err := s.drivers.FindAvailable(ctx)
if err != nil {
return nil, fmt.Errorf("find available driver: %w", err)
}
r := domain.NewRide(riderID, pickup, dropoff)
if err := r.MatchDriver(driverID); err != nil {
return nil, fmt.Errorf("match driver: %w", err)
}
created, err := s.rides.Create(ctx, r)
if err != nil {
return nil, fmt.Errorf("create ride: %w", err)
}
if err := s.drivers.SetAvailability(ctx, driverID, false); err != nil {
return nil, fmt.Errorf("set driver unavailable: %w", err)
}
return created, nil
}
func (s *Service) AcceptRide(ctx context.Context, rideID, driverID string) (*domain.Ride, error) {
r, err := s.rides.GetByID(ctx, rideID)
if err != nil {
return nil, fmt.Errorf("get ride: %w", err)
}
if err := r.Accept(driverID); err != nil {
return nil, err
}
if err := s.rides.UpdateStatus(ctx, rideID, domain.StatusAccepted); err != nil {
return nil, fmt.Errorf("update status: %w", err)
}
return r, nil
}
func (s *Service) CompleteRide(ctx context.Context, rideID string) (*domain.Ride, error) {
r, err := s.rides.GetByID(ctx, rideID)
if err != nil {
return nil, fmt.Errorf("get ride: %w", err)
}
if err := r.Complete(); err != nil {
return nil, err
}
if err := s.rides.UpdateStatus(ctx, rideID, domain.StatusCompleted); err != nil {
return nil, fmt.Errorf("update status: %w", err)
}
if err := s.drivers.SetAvailability(ctx, r.DriverID, true); err != nil {
return nil, fmt.Errorf("set driver available: %w", err)
}
return r, nil
}
Implement the ConnectRPC handler and wire main.go
The handler is the inbound adapter in your ports-and-adapters architecture. It receives proto messages from the network, translates them into domain types, calls the application service, and maps the result back to a proto response.
- Create the directory and file for the handler by running:
mkdir -p internal/infrastructure/server
- Paste the following code into internal/infrastructure/server/handler.go:
package server
import (
"context"
"fmt"
ridev1 "taxi-backend/gen/ride/v1"
appsvc "taxi-backend/internal/application/ride"
domain "taxi-backend/internal/domain/ride"
)
// RideHandler is the inbound adapter that maps proto requests to application service calls.
type RideHandler struct {
service *appsvc.Service
}
// NewRideHandler constructs the handler with the application service dependency.
func NewRideHandler(service *appsvc.Service) *RideHandler {
return &RideHandler{service: service}
}
// RequestRide handles the RequestRide RPC.
func (h *RideHandler) RequestRide(ctx context.Context, req *ridev1.RequestRideRequest) (*ridev1.RequestRideResponse, error) {
if req.Pickup == nil || req.Dropoff == nil {
return nil, fmt.Errorf("pickup and dropoff are required")
}
ride, err := h.service.RequestRide(ctx, req.RiderId, domain.Location{
Latitude: req.Pickup.Latitude,
Longitude: req.Pickup.Longitude,
}, domain.Location{
Latitude: req.Dropoff.Latitude,
Longitude: req.Dropoff.Longitude,
})
if err != nil {
return nil, err
}
return &ridev1.RequestRideResponse{
RideId: ride.ID,
DriverId: ride.DriverID,
Status: string(ride.Status),
}, nil
}
// AcceptRide handles the AcceptRide RPC.
func (h *RideHandler) AcceptRide(ctx context.Context, req *ridev1.AcceptRideRequest) (*ridev1.AcceptRideResponse, error) {
ride, err := h.service.AcceptRide(ctx, req.RideId, req.DriverId)
if err != nil {
return nil, err
}
return &ridev1.AcceptRideResponse{
RideId: ride.ID,
Status: string(ride.Status),
}, nil
}
// CompleteRide handles the CompleteRide RPC.
func (h *RideHandler) CompleteRide(ctx context.Context, req *ridev1.CompleteRideRequest) (*ridev1.CompleteRideResponse, error) {
ride, err := h.service.CompleteRide(ctx, req.RideId)
if err != nil {
return nil, err
}
return &ridev1.CompleteRideResponse{
RideId: ride.ID,
Status: string(ride.Status),
}, nil
}
What does this code do?
The RideHandler methods match the interface generated by the simple flag in protoc-gen-connect-go. Each method takes a context.Context and a plain proto request, then returns a proto response and error.
The handler validates that pickup and dropoff locations exist, converts proto Location messages into domain Location value objects, and maps the domain Ride struct back to proto response fields.
- Save internal/infrastructure/server/handler.go.
- Now create cmd/server/main.go which wires everything together. Create the directory first:
mkdir -p cmd/server
- Paste the following code into cmd/server/main.go:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"taxi-backend/gen/ride/v1/ridev1connect"
appsvc "taxi-backend/internal/application/ride"
pginfra "taxi-backend/internal/infrastructure/postgres"
"taxi-backend/internal/infrastructure/server"
)
func main() {
ctx := context.Background()
// Read database URL from environment or use the local default
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://taxi:taxi@localhost:5432/taxi?sslmode=disable"
}
// Create a connection pool to PostgreSQL
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("connect to database: %v", err)
}
defer pool.Close()
// Construct repository adapters (outbound ports)
rideRepo := pginfra.NewRideRepository(pool)
driverRepo := pginfra.NewDriverRepository(pool)
// Construct the application service with its dependencies
service := appsvc.NewService(rideRepo, driverRepo)
// Construct the ConnectRPC handler (inbound adapter)
handler := server.NewRideHandler(service)
// Register the handler with the HTTP mux
mux := http.NewServeMux()
path, h := ridev1connect.NewRideServiceHandler(handler)
mux.Handle(path, h)
addr := ":8080"
fmt.Printf("server listening on %s\n", addr)
// Enable both HTTP/1.1 and unencrypted HTTP/2 for gRPC compatibility
p := new(http.Protocols)
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true)
s := http.Server{
Addr: addr,
Handler: mux,
Protocols: p,
}
if err := s.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}
What does this code do?
This is the composition root of your application. It creates a pgxpool connection pool, passes it to the repository constructors, passes the repositories to the service constructor, and passes the service to the handler constructor. No framework magic.
The http.Protocols block enables both HTTP/1.1 (for curl with the Connect protocol) and unencrypted HTTP/2 (for gRPC clients). This is how ConnectRPC serves both protocols from the same port.
The call ridev1connect.NewRideServiceHandler(handler) returns a path string and an http.Handler that routes all three RPCs based on the URL path.
- Save cmd/server/main.go.
- Sync your dependencies by running:
go mod tidy
- Verify the project compiles by running:
go build ./...
You should see no output if everything compiled successfully.
Seeing compile errors?
Run go mod tidy again to ensure all dependencies are resolved. Check that buf generate was run successfully in the previous step. The gen/ride/v1/ridev1connect package must exist.
help me fix Go compile errors in my ConnectRPC taxi backend
✔️ Awesome, I've got everything!
Great. Double check you have saved both handler.go and main.go.
ⓧ I'd like to double check the full code
Here is internal/infrastructure/server/handler.go:
package server
import (
"context"
"fmt"
ridev1 "taxi-backend/gen/ride/v1"
appsvc "taxi-backend/internal/application/ride"
domain "taxi-backend/internal/domain/ride"
)
type RideHandler struct {
service *appsvc.Service
}
func NewRideHandler(service *appsvc.Service) *RideHandler {
return &RideHandler{service: service}
}
func (h *RideHandler) RequestRide(ctx context.Context, req *ridev1.RequestRideRequest) (*ridev1.RequestRideResponse, error) {
if req.Pickup == nil || req.Dropoff == nil {
return nil, fmt.Errorf("pickup and dropoff are required")
}
ride, err := h.service.RequestRide(ctx, req.RiderId, domain.Location{
Latitude: req.Pickup.Latitude,
Longitude: req.Pickup.Longitude,
}, domain.Location{
Latitude: req.Dropoff.Latitude,
Longitude: req.Dropoff.Longitude,
})
if err != nil {
return nil, err
}
return &ridev1.RequestRideResponse{
RideId: ride.ID,
DriverId: ride.DriverID,
Status: string(ride.Status),
}, nil
}
func (h *RideHandler) AcceptRide(ctx context.Context, req *ridev1.AcceptRideRequest) (*ridev1.AcceptRideResponse, error) {
ride, err := h.service.AcceptRide(ctx, req.RideId, req.DriverId)
if err != nil {
return nil, err
}
return &ridev1.AcceptRideResponse{
RideId: ride.ID,
Status: string(ride.Status),
}, nil
}
func (h *RideHandler) CompleteRide(ctx context.Context, req *ridev1.CompleteRideRequest) (*ridev1.CompleteRideResponse, error) {
ride, err := h.service.CompleteRide(ctx, req.RideId)
if err != nil {
return nil, err
}
return &ridev1.CompleteRideResponse{
RideId: ride.ID,
Status: string(ride.Status),
}, nil
}
Here is cmd/server/main.go:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"taxi-backend/gen/ride/v1/ridev1connect"
appsvc "taxi-backend/internal/application/ride"
pginfra "taxi-backend/internal/infrastructure/postgres"
"taxi-backend/internal/infrastructure/server"
)
func main() {
ctx := context.Background()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://taxi:taxi@localhost:5432/taxi?sslmode=disable"
}
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
log.Fatalf("connect to database: %v", err)
}
defer pool.Close()
rideRepo := pginfra.NewRideRepository(pool)
driverRepo := pginfra.NewDriverRepository(pool)
service := appsvc.NewService(rideRepo, driverRepo)
handler := server.NewRideHandler(service)
mux := http.NewServeMux()
path, h := ridev1connect.NewRideServiceHandler(handler)
mux.Handle(path, h)
addr := ":8080"
fmt.Printf("server listening on %s\n", addr)
p := new(http.Protocols)
p.SetHTTP1(true)
p.SetUnencryptedHTTP2(true)
s := http.Server{
Addr: addr,
Handler: mux,
Protocols: p,
}
if err := s.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}
Test the full ride lifecycle
Time to prove the whole stack works end-to-end. You will start the server, request a ride, accept it, and complete it.
- Start the server by running:
go run ./cmd/server/
You should see server listening on :8080 in your terminal.
- Open a second terminal window. Request a ride by running:
curl --header "Content-Type: application/json" \
--data '{"rider_id": "rider-1", "pickup": {"latitude": 40.7128, "longitude": -74.006}, "dropoff": {"latitude": 40.758, "longitude": -73.9855}}' \
http://localhost:8080/ride.v1.RideService/RequestRide
What should I see?
You should see a JSON response with a rideId, a driverId, and status set to MATCHED. Copy the rideId and driverId values. You will use them in the next commands.
- Accept the ride by running (replace the IDs with the values from your response):
curl --header "Content-Type: application/json" \
--data '{"ride_id": "[[RIDE_ID="your ride_id from the previous response"]]", "driver_id": "[[DRIVER_ID="your driver_id from the previous response"]]"}' \
http://localhost:8080/ride.v1.RideService/AcceptRide
You should see a response with status set to ACCEPTED.
- Complete the ride by running:
curl --header "Content-Type: application/json" \
--data '{"ride_id": "[[RIDE_ID="your ride_id from the previous response"]]"}' \
http://localhost:8080/ride.v1.RideService/CompleteRide
You should see a response with status set to COMPLETED. The full ride lifecycle works.
Why does this work with plain curl?
ConnectRPC servers support the Connect protocol over HTTP/1.1. The Connect protocol uses standard JSON bodies and the URL pattern /{package}.{Service}/{Method}. This means any HTTP client can call your API without a gRPC library.
- Now verify the same endpoint works over the gRPC protocol using buf curl:
buf curl --schema . --protocol grpc --http2-prior-knowledge \
--data '{"rider_id": "rider-2", "pickup": {"latitude": 51.5074, "longitude": -0.1278}, "dropoff": {"latitude": 51.52, "longitude": -0.08}}' \
http://localhost:8080/ride.v1.RideService/RequestRide
What does this command do?
The --schema . flag tells buf curl to use your local proto files for message encoding. The --protocol grpc flag switches from the default Connect protocol to native gRPC. The --http2-prior-knowledge flag tells the client to use h2c (unencrypted HTTP/2) since your server runs without TLS.
You should see the same JSON response shape as before. This proves your single server supports both JSON/HTTP (Connect) and gRPC simultaneously with zero extra configuration.
Getting a connection error?
Make sure the server is still running in the first terminal. If port 8080 is already in use from a previous run, stop the old process with Ctrl+C and start again with go run ./cmd/server/.
If buf curl reports a schema resolution error, make sure you are running the command from the project root where buf.yaml lives.
help me debug a connection or schema error when using buf curl with my ConnectRPC server
Your DDD taxi backend is fully operational. The domain enforces state transitions, repositories persist data, the application service orchestrates, and ConnectRPC exposes it all over both JSON/HTTP and gRPC from a single binary.
Secret mission
Add a Domain Event Bus
Your taxi backend works. But right now, each bounded context is tightly coupled. When a ride is requested, only the application service knows about it. What if a notifications service needs to send the rider a confirmation? What if an analytics service needs to track demand? Add an in-memory domain event bus that publishes RideRequested and DriverMatched events, decoupling your bounded contexts for future async processing.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A PostgreSQL container running via Docker Compose (local, no cost).
- The taxi-backend project folder on your Desktop.
- The Buf CLI binary installed at /usr/local/bin/buf.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the taxi backend.
- Your PostgreSQL container continues running in the background.
- Your Go server can be restarted any time with go run ./cmd/server from the project folder.
- The Buf CLI remains available system-wide for future protobuf projects.
Pause - I'll come back to this later
Stop the running containers to free up system resources while keeping your data intact.
- Stop your Go server by pressing Ctrl+C in the terminal where it is running.
- Stop the PostgreSQL container by running this from your taxi-backend folder:
docker compose stop
Your data volume is preserved. When you come back, run docker compose start to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database data permanently.
- Stop your Go server by pressing Ctrl+C in the terminal where it is running.
- Remove the PostgreSQL container and its data volume by running this from your taxi-backend folder:
docker compose down -v
- Delete the project folder:
macOS
cd ~/Desktop
rm -rf taxi-backend
Windows
cd ~\Desktop
rmdir /s /q taxi-backend
- Remove the Buf CLI binary:
macOS
rm /usr/local/bin/buf
Windows
del C:\usr\local\bin\buf.exe
Nice Work!
Nice Work!
Nice work! You just built a production-structured taxi ride-request backend in Go, designed from the ground up with Domain-Driven Design and a proto-first API.
You've learned how to:
- Built a DDD modular monolith in Go with a Ride aggregate that enforces state transitions (REQUESTED, MATCHED, ACCEPTED, COMPLETED) in the domain layer.
- Used buf and ConnectRPC to define a proto-first API that simultaneously supports JSON/HTTP and gRPC protocols with zero extra configuration.
- Structured the codebase with ports and adapters so every module boundary is an interface, ready for microservice extraction by swapping function calls for gRPC clients.
- Secret Mission: Added an in-memory domain event bus, demonstrating how to decouple bounded contexts for future async processing with NATS or Kafka.
Ready to quiz yourself?