Introduction
Modern microservices require clean separation of concerns, testable code, and efficient inter-service communication. In this comprehensive tutorial, we'll explore three powerful patterns that work together to create maintainable, scalable Go applications:
- gRPC for fast, type-safe service-to-service communication
- Dependency Injection using Uber Fx for clean architecture
- Hexagonal Architecture for flexible, testable business logic
By the end of this article, you'll understand how to combine these patterns to build professional-grade microservices in Go.
Understanding gRPC
gRPC is a high-performance, open-source framework developed by Google. The name stands for Google Remote Procedure Call. Let's break down what that means.
What is Remote Procedure Call (RPC)?
A Remote Procedure Call (RPC) is a protocol that allows a program on one computer to execute a function on another computer as if it were a local call. This abstraction makes distributed systems feel more like monolithic applications.
Consider this simple function in pseudo-code:
func calculateAreaOfSquare(s float64) float64 {
area := s * s
return area
}
Now imagine our programming environment doesn't have a multiplication function, and we need to calculate the area using an external service.
Traditional HTTP API Approach
Without gRPC, you might call an external service like this:
func calculateAreaOfSquare(s float64) float64 {
// Call an external math API over HTTP
area := xhr.fetch("api.math.edu/multiply?s=" + s + "&t=" + s)
return area
}
The problem? Standard HTTP APIs typically operate over HTTP/1.1, where you send a request and wait for a response. This involves:
- Text-based serialization (JSON/XML)
- Request/response overhead
- Slower performance
- Manual client implementation
The gRPC Advantage
gRPC makes remote calls feel like local function calls:
func calculateAreaOfSquare(s float64) float64 {
// gRPC call - looks and feels local
area := multiply(s, s)
return area
}
Even though the multiply function might be implemented on another server or even written in a different language like Java, Python, or Rust you can call it as if it were part of your Go application.
Why Choose gRPC?
Key Benefits:
- Fast: Uses HTTP/2 with binary serialization (Protocol Buffers)
- Type-safe: Strongly-typed contracts prevent runtime errors
- Language-agnostic: Write services in any language gRPC supports
- Bidirectional streaming: Supports client, server, and bidirectional streaming
- Built-in code generation: Automatically generates client and server code
Real-world example: You could build a web server in Go that leverages specialized machine learning functions from a Python service, or authentication services from a Java application all with seamless, performant communication.
gRPC Contracts: The Foundation
gRPC operates on the principle of contracts. When services communicate especially across different programming languages there must be a clear agreement on:
- Available functions: What can be called?
- Input parameters: What data does each function accept and what are their types?
- Return values: What does each function return?
This contract is defined using Protocol Buffers (protobufs), a language-neutral serialization format.
What gRPC Provides
gRPC doesn't care about how a function is implemented. Instead, it provides:
- Accessors for arguments: Methods to read the data you send
- Accessors for responses: Methods to read the data you receive
That's all you need: pass the required arguments and receive results, exactly like calling a local function.
gRPC in Action: Protobuf Contracts
Let's look at a practical example a simple greeting service defined in Protocol Buffers:
// The greeting service definition
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name
message HelloRequest {
string name = 1;
}
// The response message containing the greeting
message HelloReply {
string message = 1;
}
Breaking it down:
- We define a service called
Greeter - It has an RPC method
SayHello -
SayHellotakes aHelloRequestmessage with anamefield (type: string) - It returns a
HelloReplymessage with amessagefield (type: string)
The contract explicitly specifies expected inputs and outputs, allowing gRPC to automatically generate type-safe client and server code.
Creating Our Calculator Service
Let's define a more practical example a calculator service:
// The calculator service definition
service Calculator {
// Adds two numbers
rpc Add (OperationRequest) returns (OperationResponse) {}
}
message OperationRequest {
float a = 1;
float b = 2;
}
message OperationResponse {
float result = 1;
}
Understanding the structure:
- Calculator is the service (a logical grouping of related methods)
- Add is an RPC method (we're using unary RPC one request, one response)
-
OperationRequest contains two fields:
aandb -
OperationResponse contains a single field:
result
Note: There are four types of gRPC methods: unary, client streaming, server streaming, and bidirectional streaming. This tutorial focuses on unary RPCs (single request → single response) for simplicity.
For now, we've defined just one operation. Once comfortable with the pattern, you can extend the service with subtraction, multiplication, division, and more complex operations.
Setting Up Your gRPC Development Environment
Before writing code, let's set up all necessary tools and dependencies.
1. Install Go
Verify Go is installed:
go version
If not installed, download from: https://golang.org/dl/
2. Initialize a Go Module
Create your project directory and initialize a module:
mkdir grpc-hexagonal-demo
cd grpc-hexagonal-demo
go mod init github.com/yourusername/grpc-hexagonal-demo
This creates a go.mod file to manage your project's dependencies.
3. Install gRPC and Protobuf Support
Install the gRPC package for Go:
go get google.golang.org/grpc
go get google.golang.org/protobuf
4. Install Protocol Buffers Compiler (protoc)
macOS (using Homebrew):
brew install protobuf
Linux (Debian/Ubuntu):
sudo apt install -y protobuf-compiler
Verify installation:
protoc --version
You should see output like: libprotoc 3.x.x or newer.
5. Install Go Plugins for Protobuf
These plugins generate Go code from .proto files:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Add Go bin to your PATH:
export PATH="$PATH:$(go env GOPATH)/bin"
Add this line to your shell profile (~/.bashrc, ~/.zshrc, etc.) to make it permanent.
6. Verify Your Setup
Test that code generation works:
protoc --go_out=. --go-grpc_out=. pkg/protos/*.proto
If no errors appear, your environment is ready!
Project Structure
We'll organize our project following Go best practices:
grpc-hexagonal-demo/
├── cmd/
│ └── server/
│ └── main.go # Server entry point
├── internal/
│ ├── app/ # Fx modules and wiring
│ ├── domain/ # Core business logic and ports
│ ├── services/ # Domain service implementations
│ └── adapters/
│ ├── grpc/ # gRPC handlers (driving adapter)
│ └── db/ # Repository implementations (driven adapter)
├── pkg/
│ └── protos/ # Protobuf definitions
│ └── calculator.proto
├── go.mod
├── go.sum
└── Taskfile.yml # Task automation
Directory purposes:
- cmd/server/main.go: Application entry point
- internal/app: Dependency injection wiring with Uber Fx
- internal/domain: Pure business logic and interface definitions (ports)
- internal/services: Business logic implementations
- internal/adapters/grpc: gRPC server handlers
- internal/adapters/db: Database repositories (when needed)
-
pkg/protos: Protobuf contracts (
.protofiles) - Taskfile.yml: Task runner for automating repetitive commands
Automating Code Generation with Taskfile
Instead of manually running protoc every time you modify your .proto files, use Taskfile (a modern alternative to Makefiles).
Install Taskfile:
# macOS
brew install go-task/tap/go-task
# Linux
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
# Or using Go
go install github.com/go-task/task/v3/cmd/task@latest
Create Taskfile.yml:
version: "3"
tasks:
proto-gen:
desc: Generate Go code from protobuf files
cmds:
- protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
pkg/protos/*.proto
run:
desc: Run the gRPC server
cmds:
- go run cmd/server/main.go
build:
desc: Build the server binary
cmds:
- go build -o bin/server cmd/server/main.go
Usage:
# Generate protobuf code
task proto-gen
# Run the server
task run
# Build the binary
task build
Taskfile simplifies your workflow and makes commands discoverable for team members.
Defining the Protobuf Contract
Create your calculator service contract:
File: pkg/protos/calculator.proto
syntax = "proto3";
package calculator;
option go_package = "github.com/yourusername/grpc-hexagonal-demo/pkg/protos";
// The calculator service definition
service Calculator {
// Adds two numbers
rpc Add (OperationRequest) returns (OperationResponse) {}
// Multiplies two numbers
rpc Multiply (OperationRequest) returns (OperationResponse) {}
// Divides two numbers
rpc Divide (OperationRequest) returns (OperationResponse) {}
}
// Request message containing two operands
message OperationRequest {
float a = 1;
float b = 2;
}
// Response message containing the result
message OperationResponse {
float result = 1;
}
Generate the Go code:
task proto-gen
This creates two files in pkg/protos/:
-
calculator.pb.go: Message definitions -
calculator_grpc.pb.go: Service interface and client code
Creating a Basic gRPC Server
Now let's create a minimal gRPC server to understand the fundamentals.
File: cmd/server/main.go
package main
import (
"log"
"net"
pb "github.com/yourusername/grpc-hexagonal-demo/pkg/protos"
"google.golang.org/grpc"
)
func main() {
// Create a TCP listener on port 50051
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// Create a new gRPC server
s := grpc.NewServer()
// TODO: Register our calculator service here
// pb.RegisterCalculatorServer(s, &services.CalculatorServer{})
log.Println("gRPC server running on port 50051...")
// Start serving requests
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Understanding the code:
If you're familiar with creating HTTP servers in Go, this should look similar:
- Create a TCP listener on port 50051
- Create a gRPC server instance
- Register services (we'll do this next)
- Start serving requests
At this point, the server will start but won't handle any requests because we haven't implemented the service methods yet.
Implementing the Calculator Service
Let's implement the actual business logic for our calculator operations.
File: internal/services/calculator.go
package services
import (
"context"
"errors"
pb "github.com/yourusername/grpc-hexagonal-demo/pkg/protos"
)
// CalculatorServer implements the Calculator gRPC service
type CalculatorServer struct {
pb.UnimplementedCalculatorServer
}
// NewCalculatorServer creates a new calculator server instance
func NewCalculatorServer() *CalculatorServer {
return &CalculatorServer{}
}
// Add implements the Add RPC method
func (c *CalculatorServer) Add(ctx context.Context, req *pb.OperationRequest) (*pb.OperationResponse, error) {
result := req.A + req.B
return &pb.OperationResponse{
Result: result,
}, nil
}
// Multiply implements the Multiply RPC method
func (c *CalculatorServer) Multiply(ctx context.Context, req *pb.OperationRequest) (*pb.OperationResponse, error) {
result := req.A * req.B
return &pb.OperationResponse{
Result: result,
}, nil
}
// Divide implements the Divide RPC method
func (c *CalculatorServer) Divide(ctx context.Context, req *pb.OperationRequest) (*pb.OperationResponse, error) {
if req.B == 0 {
return nil, errors.New("cannot divide by zero")
}
result := req.A / req.B
return &pb.OperationResponse{
Result: result,
}, nil
}
Key points:
- UnimplementedCalculatorServer: Embedding this ensures forward compatibility if new methods are added to the proto
- Context parameter: Used for cancellation, deadlines, and request-scoped values
- Error handling: The Divide method demonstrates proper error handling (division by zero)
- Each method returns a pointer to
OperationResponse
Registering the Service
Update cmd/server/main.go to register our calculator service:
package main
import (
"log"
"net"
"github.com/yourusername/grpc-hexagonal-demo/internal/services"
pb "github.com/yourusername/grpc-hexagonal-demo/pkg/protos"
"google.golang.org/grpc"
)
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
// Register the calculator service
calcServer := services.NewCalculatorServer()
pb.RegisterCalculatorServer(s, calcServer)
log.Println("gRPC server running on port 50051...")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Run your server:
task run
# or
go run cmd/server/main.go
You should see: gRPC server running on port 50051...
Testing with Postman
Postman has excellent support for gRPC, making it easy to test your services.
Setting Up gRPC in Postman
- Create a new request and select gRPC as the protocol
-
Enter the server URL:
localhost:50051 -
Import your proto file: Click "Select a method" → "Import .proto file" → Navigate to
pkg/protos/calculator.proto -
Select a method: Choose
Calculator.Add,Calculator.Multiply, orCalculator.Divide
Making a Request
For the Add method:
Request:
{
"a": 5,
"b": 3
}
Response:
{
"result": 8
}
Try the same for Multiply and Divide to verify all methods work correctly.
What Just Happened?
- Postman read the protobuf contract to understand available methods and their signatures
- It constructed a valid gRPC request with the data you provided
- Your server received the request, called the appropriate method, and returned the result
- Postman decoded the binary response and displayed it as JSON
Without the contract, the client wouldn't know what methods exist or what data to send. This is the power of contract-first development with gRPC.
Understanding Dependency Injection (DI)
Before diving into Hexagonal Architecture, we need to understand Dependency Injection because DI is what makes hexagonal architecture flexible, testable, and maintainable.
What is Dependency Injection?
Dependency Injection is a design pattern where:
A component does not create the things it depends on those dependencies are provided to it from the outside.
Why DI Matters
Without DI, your code becomes tightly coupled:
- Handlers create services
- Services create repositories
- Repositories create database clients
- Everything is hard-coded and nearly impossible to unit test
With DI, you gain:
- ✅ Flexibility: Replace implementations easily
- ✅ Testability: Mock dependencies in unit tests
- ✅ Clarity: Dependencies are explicit and visible
- ✅ Maintainability: Changes to one component don't cascade everywhere
Example: Bad (Non-DI) Code
type UserService struct {
repo *PostgresRepo
}
func NewUserService() *UserService {
return &UserService{
repo: NewPostgresRepo(), // Tightly coupled!
}
}
Problems:
- The service is hard-wired to PostgreSQL
- You can't test without a real database
- Switching to MySQL or MongoDB requires code changes throughout
Example: Good (DI-Friendly) Code
type UserRepository interface {
FindByID(id string) (*User, error)
Save(user *User) error
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{
repo: repo,
}
}
Benefits:
- Now you can plug in:
-
PostgresRepofor production -
MySQLRepofor a different deployment -
InMemoryRepofor tests -
MockRepofor unit tests
-
The Dependency Problem at Scale
For small apps, manual wiring is manageable:
func main() {
db := NewPostgresDB()
repo := NewPostgresRepo(db)
svc := NewUserService(repo)
handler := NewGRPCHandler(svc)
server := NewGRPCServer(handler)
server.Start()
}
But in real microservices with dozens of dependencies:
- Manual wiring becomes error-prone
- Initialization order matters
- Lifecycle management (startup/shutdown) is complex
- Circular dependencies are hard to detect
This is where Uber Fx comes in.
Introducing Uber Fx for Dependency Injection
Uber Fx is a dependency injection framework for Go that automates the wiring of your application's dependencies.
What Fx Provides
- ✅ Automatic dependency graph building: Fx figures out the correct initialization order
- ✅ Missing dependency detection: Fails fast if dependencies are missing
- ✅ Lifecycle management: Clean startup and shutdown hooks
- ✅ Constructor validation: Ensures all dependencies can be satisfied at compile time (with proper setup)
How Fx Works
You declare providers (constructors) and invokers (startup functions), and Fx does the rest:
fx.New(
fx.Provide(
NewDatabase, // Provides *Database
NewRepository, // Provides *Repository (needs *Database)
NewService, // Provides *Service (needs *Repository)
NewHandler, // Provides *Handler (needs *Service)
),
fx.Invoke(StartServer), // Starts the server (needs *Handler)
)
Fx automatically:
- Calls constructors in the correct order
- Passes dependencies to each constructor
- Invokes startup functions with all dependencies satisfied
- Manages graceful shutdown
Visualizing the Dependency Graph
Here's how Fx wires our calculator service:
graph LR
subgraph "Fx Providers"
P1[NewInMemoryRepo]
P2[NewCalculatorService]
P3[NewCalculatorHandler]
P4[NewGRPCServer]
end
subgraph "Dependencies"
D1[Repository]
D2[CalculatorPort]
D3[Handler]
D4[Server]
end
subgraph "Invoker"
I1[RegisterServer]
end
P1 -->|provides| D1
P2 -->|needs D1, provides| D2
P3 -->|needs D2, provides| D3
P4 -->|provides| D4
D3 -->|used by| I1
D4 -->|used by| I1
I1 -->|starts| SERVER[gRPC Server :50051]
style P1 fill:#FFC107,stroke:#F57F17,stroke-width:2px
style P2 fill:#FFC107,stroke:#F57F17,stroke-width:2px
style P3 fill:#FFC107,stroke:#F57F17,stroke-width:2px
style P4 fill:#FFC107,stroke:#F57F17,stroke-width:2px
style I1 fill:#E91E63,stroke:#880E4F,stroke-width:2px,color:#fff
style SERVER fill:#4CAF50,stroke:#1B5E20,stroke-width:3px,color:#fff
Key:
- 🟡 Yellow: Providers (constructors)
- 🔵 Blue: Created dependencies
- 🔴 Pink: Invoker (startup function)
- 🟢 Green: Running server
Hexagonal Architecture + Uber Fx + gRPC
Now we combine everything into a clean, maintainable architecture.
Understanding Hexagonal Architecture
Hexagonal Architecture (also known as Ports and Adapters) separates your application into three main layers:
1. Domain Layer (Core)
The heart of your application:
- Pure business logic: No external dependencies
- Domain entities: Your core data structures
- Ports (interfaces): Define contracts for external interactions
- No framework code: Could theoretically run in any environment
2. Adapters Layer
Implementations that connect your domain to the outside world:
- Driving adapters (inbound): gRPC handlers, HTTP controllers, CLI commands
- Driven adapters (outbound): Database repositories, external API clients, message queues
3. Application Layer
The glue that wires everything together:
- Dependency injection setup (Fx modules)
- Configuration management
- Application lifecycle
Visualizing Hexagonal Architecture
Here's how the layers interact in our calculator service:
graph TB
subgraph "Driving Adapters (Inbound)"
A1[gRPC Handler]
end
subgraph "Domain Core (Business Logic)"
P1[Ports/Interfaces]
S1[Calculator Service]
end
subgraph "Driven Adapters (Outbound)"
D1[Repository]
end
A1 -->|depends on| P1
P1 -.implements.- S1
S1 -->|depends on| P1
P1 -.implements.- D1
style P1 fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff
style S1 fill:#2196F3,stroke:#1565C0,stroke-width:2px,color:#fff
style A1 fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff
style D1 fill:#9C27B0,stroke:#4A148C,stroke-width:2px,color:#fff
Key principles:
- 🟢 Green (Ports): Pure interfaces that define contracts
- 🔵 Blue (Domain): Business logic with no external dependencies
- 🟠 Orange (Driving): Inbound adapters that trigger use cases
- 🟣 Purple (Driven): Outbound adapters that provide infrastructure
Project Structure with Hexagonal Architecture
grpc-hexagonal-demo/
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── internal/
│ ├── app/ # Fx modules and wiring
│ │ └── modules.go
│ ├── domain/ # Core business logic
│ │ ├── entities.go # Domain entities
│ │ └── ports.go # Interface definitions
│ ├── services/ # Domain service implementations
│ │ └── calculator_service.go
│ └── adapters/
│ ├── grpc/ # gRPC handlers (driving adapter)
│ │ └── calculator_handler.go
│ └── db/ # Repository implementations (driven adapter)
│ └── calculator_repo.go
├── pkg/
│ └── protos/
│ └── calculator.proto
├── go.mod
└── Taskfile.yml
Implementing Hexagonal Architecture: Step by Step
Step 1: Define Ports (Domain Interfaces)
The domain defines what it needs, not how it's implemented.
File: internal/domain/ports.go
package domain
// CalculatorPort defines the business operations our domain provides
type CalculatorPort interface {
Add(a, b float32) float32
Multiply(a, b float32) float32
Divide(a, b float32) (float32, error)
}
Key points:
- This is your core contract
- No implementation details
- No external dependencies
- Your gRPC layer will depend on this interface, not concrete implementations
Step 2: Implement the Domain Service
File: internal/services/calculator_service.go
package services
import (
"errors"
"github.com/yourusername/grpc-hexagonal-demo/internal/domain"
)
// CalculatorService implements domain.CalculatorPort
type CalculatorService struct{}
// NewCalculatorService creates a new calculator service
func NewCalculatorService() domain.CalculatorPort {
return &CalculatorService{}
}
func (c *CalculatorService) Add(a, b float32) float32 {
return a + b
}
func (c *CalculatorService) Multiply(a, b float32) float32 {
return a * b
}
func (c *CalculatorService) Divide(a, b float32) (float32, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}
Notice:
- ✅ No gRPC code here
- ✅ No database code here
- ✅ Pure business logic
- ✅ Returns the interface type from the constructor
This is the essence of clean architecture: the domain knows nothing about delivery mechanisms or infrastructure.
Request Flow Visualization
Let's trace a request through our hexagonal architecture:
sequenceDiagram
participant C as Client (Postman)
participant G as gRPC Handler
participant P as CalculatorPort
participant S as CalculatorService
participant R as Repository
C->>G: Calculator.Add(a=5, b=3)
Note over G: Adapter convertsgRPC to domain
G->>P: Add(5, 3)
Note over P: Interface/Port
P->>S: Add(5, 3)
Note over S: Business logica + b = 8
S->>R: Save(history)
Note over R: Store calculation
R-->>S: ✓
S-->>P: 8
P-->>G: 8
Note over G: Adapter convertsdomain to gRPC
G-->>C: OperationResponse{result: 8}
Note over C,R: 🎯 Key insight: Domain (S) never knows about gRPC or storage
Notice how:
- The handler translates between gRPC and domain types
- The service only works with pure Go types (float32)
- The repository is just an interface to the service
- Each layer has a single, clear responsibility
Step 3: Implement the gRPC Adapter (Driving)
The gRPC adapter translates between the gRPC protocol and our domain.
File: internal/adapters/grpc/calculator_handler.go
package grpc
import (
"context"
pb "github.com/yourusername/grpc-hexagonal-demo/pkg/protos"
"github.com/yourusername/grpc-hexagonal-demo/internal/domain"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// CalculatorHandler is a gRPC adapter for the calculator service
type CalculatorHandler struct {
pb.UnimplementedCalculatorServer
svc domain.CalculatorPort
}
// NewCalculatorHandler creates a new gRPC calculator handler
func NewCalculatorHandler(svc domain.CalculatorPort) *CalculatorHandler {
return &CalculatorHandler{
svc: svc,
}
}
// Add handles the Add RPC
func (h *CalculatorHandler) Add(ctx context.Context, req *pb.OperationRequest) (*pb.OperationResponse, error) {
result := h.svc.Add(req.A, req.B)
return &pb.OperationResponse{Result: result}, nil
}
// Multiply handles the Multiply RPC
func (h *CalculatorHandler) Multiply(ctx context.Context, req *pb.OperationRequest) (*pb.OperationResponse, error) {
result := h.svc.Multiply(req.A, req.B)
return &pb.OperationResponse{Result: result}, nil
}
// Divide handles the Divide RPC
func (h *CalculatorHandler) Divide(ctx context.Context, req *pb.OperationRequest) (*pb.OperationResponse, error) {
result, err := h.svc.Divide(req.A, req.B)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, err.Error())
}
return &pb.OperationResponse{Result: result}, nil
}
Key observations:
- ✅ The handler depends on the interface, not the concrete implementation
- ✅ It translates gRPC requests/responses to domain calls
- ✅ It handles gRPC-specific error codes
- ✅ This is the only place that knows about gRPC
Step 4: Wire Everything with Uber Fx
Now let's bring it all together using Fx.
First, install Fx:
go get go.uber.org/fx
File: internal/app/modules.go
package app
import (
"context"
"net"
"go.uber.org/fx"
"google.golang.org/grpc"
"github.com/yourusername/grpc-hexagonal-demo/internal/services"
grpcHandler "github.com/yourusername/grpc-hexagonal-demo/internal/adapters/grpc"
pb "github.com/yourusername/grpc-hexagonal-demo/pkg/protos"
)
// Module contains all application dependencies
var Module = fx.Options(
fx.Provide(
// Domain layer
services.NewCalculatorService,
// Adapter layer
grpcHandler.NewCalculatorHandler,
// Infrastructure
NewGRPCServer,
),
fx.Invoke(RegisterServer),
)
// NewGRPCServer creates a new gRPC server instance
func NewGRPCServer() *grpc.Server {
return grpc.NewServer()
}
// RegisterServer registers all gRPC services and starts the server
func RegisterServer(
lc fx.Lifecycle,
server *grpc.Server,
handler *grpcHandler.CalculatorHandler,
) {
// Register the calculator handler
pb.RegisterCalculatorServer(server, handler)
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
return err
}
go func() {
if err := server.Serve(lis); err != nil {
panic(err)
}
}()
println("gRPC server started on :50051")
return nil
},
OnStop: func(ctx context.Context) error {
println("Gracefully stopping gRPC server...")
server.GracefulStop()
return nil
},
})
}
Understanding the code:
- fx.Provide: Declares constructors that Fx should call
- fx.Invoke: Declares functions that should run at startup
- fx.Lifecycle: Manages application startup and shutdown
- OnStart hook: Starts the gRPC server in a goroutine
- OnStop hook: Gracefully stops the server on shutdown
Complete System Visualization
Here's everything wired together:
graph TB
subgraph "Client"
CLIENT[Postman/grpcurl]
end
subgraph "Infrastructure (Fx)"
FX[Fx Container]
LC[Lifecycle Manager]
end
subgraph "Adapters"
GRPC[gRPC Handler:50051]
REPO[Repository]
end
subgraph "Domain"
PORT_IN[CalculatorPort]
PORT_OUT[CalcRepository]
SVC[CalculatorService]
end
subgraph "Contract"
PROTO[calculator.proto]
end
CLIENT -->|gRPC Request| GRPC
GRPC -.uses.- PROTO
FX -->|provides| GRPC
FX -->|provides| SVC
FX -->|provides| REPO
GRPC -->|depends on| PORT_IN
PORT_IN -.implemented by.- SVC
SVC -->|depends on| PORT_OUT
PORT_OUT -.implemented by.- REPO
LC -->|manages| GRPC
style FX fill:#2196F3,stroke:#0D47A1,stroke-width:3px,color:#fff
style LC fill:#2196F3,stroke:#0D47A1,stroke-width:2px,color:#fff
style GRPC fill:#FF9800,stroke:#E65100,stroke-width:2px,color:#fff
style REPO fill:#9C27B0,stroke:#4A148C,stroke-width:2px,color:#fff
style PORT_IN fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff
style PORT_OUT fill:#4CAF50,stroke:#2E7D32,stroke-width:3px,color:#fff
style SVC fill:#2196F3,stroke:#1565C0,stroke-width:2px,color:#fff
What's happening:
- 🔵 Fx builds all dependencies in the correct order
- 🟢 Ports (interfaces) keep everything decoupled
- 🟠 gRPC handler only knows about the port, not the implementation
- 🟣 Repository implements a port, can be swapped easily
- 🔵 Lifecycle manager handles clean startup/shutdown
Step 5: Update main.go
File: cmd/server/main.go
package main
import (
"go.uber.org/fx"
"github.com/yourusername/grpc-hexagonal-demo/internal/app"
)
func main() {
fx.New(
app.Module,
).Run()
}
That's it! Just 7 lines of code in main.go.
Fx automatically:
- ✅ Builds the dependency graph
- ✅ Initializes all components in the correct order
- ✅ Starts the server
- ✅ Handles graceful shutdown (Ctrl+C)
Running and Testing the Complete Application
Start the Server
task run
You should see:
[Fx] PROVIDE *services.CalculatorService <= services.NewCalculatorService()
[Fx] PROVIDE *grpc.CalculatorHandler <= grpc.NewCalculatorHandler()
[Fx] PROVIDE *grpc.Server <= app.NewGRPCServer()
[Fx] INVOKE app.RegisterServer()
gRPC server started on :50051
[Fx] RUNNING
Test with Postman
- Create a new gRPC request
- Connect to
localhost:50051 - Import
pkg/protos/calculator.proto - Test each method:
Calculator.Add:
Request: {"a": 10, "b": 5}
Response: {"result": 15}
Calculator.Multiply:
Request: {"a": 7, "b": 8}
Response: {"result": 56}
Calculator.Divide:
Request: {"a": 20, "b": 4}
Response: {"result": 5}
Request: {"a": 10, "b": 0}
Error: "cannot divide by zero"
Benefits of This Architecture
Let's see why this architectural approach is worth the effort.
Architectural Comparison
Traditional Approach (Tightly Coupled)
graph TD
H[HTTP Handler]
S[Service]
P[PostgreSQL Client]
H -->|directly creates| S
S -->|directly creates| P
style H fill:#FF5252,stroke:#C62828,stroke-width:2px,color:#fff
style S fill:#FF5252,stroke:#C62828,stroke-width:2px,color:#fff
style P fill:#FF5252,stroke:#C62828,stroke-width:2px,color:#fff
Problems:
- ❌ Can't test service without a real database
- ❌ Can't switch databases without rewriting service code
- ❌ Can't add gRPC without major refactoring
- ❌ Service knows about HTTP, database, and business logic
Hexagonal Approach (Decoupled)
graph TD
H1[HTTP Handler]
H2[gRPC Handler]
PORT[CalculatorPort Interface]
S[Service]
PORT2[Repository Interface]
P1[PostgreSQL]
P2[MongoDB]
M[Mock for Tests]
H1 -->|depends on| PORT
H2 -->|depends on| PORT
PORT -.implemented by.- S
S -->|depends on| PORT2
PORT2 -.implemented by.- P1
PORT2 -.implemented by.- P2
PORT2 -.implemented by.- M
style H1 fill:#4CAF50,stroke:#1B5E20,stroke-width:2px,color:#fff
style H2 fill:#4CAF50,stroke:#1B5E20,stroke-width:2px,color:#fff
style PORT fill:#2196F3,stroke:#0D47A1,stroke-width:3px,color:#fff
style S fill:#4CAF50,stroke:#1B5E20,stroke-width:2px,color:#fff
style PORT2 fill:#2196F3,stroke:#0D47A1,stroke-width:3px,color:#fff
style P1 fill:#4CAF50,stroke:#1B5E20,stroke-width:2px,color:#fff
style P2 fill:#4CAF50,stroke:#1B5E20,stroke-width:2px,color:#fff
style M fill:#FFC107,stroke:#F57F17,stroke-width:2px
Benefits:
- ✅ Easy to test with mocks
- ✅ Easy to switch databases (just change wiring)
- ✅ Easy to add new adapters (HTTP, gRPC, CLI)
- ✅ Service only knows about business logic
1. Testability
You can now easily unit test your domain logic without gRPC or any infrastructure:
func TestCalculatorService_Add(t *testing.T) {
svc := services.NewCalculatorService()
result := svc.Add(5, 3)
if result != 8 {
t.Errorf("expected 8, got %f", result)
}
}
2. Flexibility
Want to add an HTTP REST API alongside gRPC? Just create a new adapter:
// internal/adapters/http/calculator_handler.go
type CalculatorHTTPHandler struct {
svc domain.CalculatorPort
}
func (h *CalculatorHTTPHandler) Add(w http.ResponseWriter, r *http.Request) {
// Parse request, call h.svc.Add(), return JSON
}
3. Maintainability
- Domain changes don't affect adapters
- gRPC changes don't affect the domain
- Adding features means implementing the interface and wiring it up
4. Clear Dependencies
Looking at the Fx module tells you exactly what your application needs:
fx.Provide(
services.NewCalculatorService, // Domain
grpcHandler.NewCalculatorHandler, // Adapter
NewGRPCServer, // Infrastructure
)
Advanced: Adding a Database Repository
Let's extend our architecture to include a database for storing calculation history.
Step 1: Define the Repository Port
File: internal/domain/ports.go
package domain
type CalculationHistory struct {
ID string
Operation string
A float32
B float32
Result float32
}
// CalculationRepository defines how we store calculation history
type CalculationRepository interface {
Save(history CalculationHistory) error
FindAll() ([]CalculationHistory, error)
}
Step 2: Update the Service to Use the Repository
File: internal/services/calculator_service.go
package services
import (
"errors"
"github.com/yourusername/grpc-hexagonal-demo/internal/domain"
)
type CalculatorService struct {
repo domain.CalculationRepository
}
func NewCalculatorService(repo domain.CalculationRepository) domain.CalculatorPort {
return &CalculatorService{
repo: repo,
}
}
func (c *CalculatorService) Add(a, b float32) float32 {
result := a + b
// Save to history
c.repo.Save(domain.CalculationHistory{
Operation: "add",
A: a,
B: b,
Result: result,
})
return result
}
// ... similar for Multiply and Divide
Step 3: Implement an In-Memory Repository
File: internal/adapters/db/memory_repo.go
package db
import (
"sync"
"github.com/yourusername/grpc-hexagonal-demo/internal/domain"
)
type InMemoryRepo struct {
mu sync.RWMutex
history []domain.CalculationHistory
}
func NewInMemoryRepo() domain.CalculationRepository {
return &InMemoryRepo{
history: make([]domain.CalculationHistory, 0),
}
}
func (r *InMemoryRepo) Save(calc domain.CalculationHistory) error {
r.mu.Lock()
defer r.mu.Unlock()
r.history = append(r.history, calc)
return nil
}
func (r *InMemoryRepo) FindAll() ([]domain.CalculationHistory, error) {
r.mu.RLock()
defer r.mu.RUnlock()
return r.history, nil
}
Step 4: Wire It All Together
File: internal/app/modules.go
var Module = fx.Options(
fx.Provide(
// Driven adapters (repositories)
db.NewInMemoryRepo,
// Domain layer
services.NewCalculatorService,
// Driving adapters (handlers)
grpcHandler.NewCalculatorHandler,
// Infrastructure
NewGRPCServer,
),
fx.Invoke(RegisterServer),
)
That's it! Fx automatically wires the repository into the service.
Later, you can replace InMemoryRepo with PostgresRepo or MongoRepo without touching the domain logic.
Production Considerations
Configuration Management
Use environment variables or config files:
type Config struct {
GRPCPort string
DBUrl string
}
func NewConfig() *Config {
return &Config{
GRPCPort: os.Getenv("GRPC_PORT"),
DBUrl: os.Getenv("DATABASE_URL"),
}
}
// In Fx module
fx.Provide(NewConfig)
Logging
Add structured logging with zap:
import "go.uber.org/zap"
fx.Provide(
zap.NewProduction, // or zap.NewDevelopment
)
func NewCalculatorService(logger *zap.Logger, repo domain.CalculationRepository) domain.CalculatorPort {
return &CalculatorService{
logger: logger,
repo: repo,
}
}
Middleware
Add interceptors for authentication, logging, etc.:
func NewGRPCServer(logger *zap.Logger) *grpc.Server {
return grpc.NewServer(
grpc.ChainUnaryInterceptor(
LoggingInterceptor(logger),
AuthInterceptor(),
),
)
}
Health Checks
Implement gRPC health checks:
import "google/health/v1/health.proto";
Conclusion
In this comprehensive tutorial, we've covered:
✅ gRPC fundamentals: Protocol Buffers, RPC methods, and efficient service communication
✅ Dependency Injection with Uber Fx: Automatic dependency graph building and lifecycle management
✅ Hexagonal Architecture: Clean separation between domain, adapters, and infrastructure
✅ Production-ready patterns: Testability, flexibility, and maintainability
Key Takeaways
- gRPC provides fast, type-safe communication between services using Protocol Buffers and HTTP/2
- Dependency Injection decouples components and makes testing straightforward
- Hexagonal Architecture keeps business logic pure and independent of delivery mechanisms
- Uber Fx automates the tedious work of wiring dependencies
Next Steps
- Add more operations (subtraction, exponentiation, etc.)
- Implement a PostgreSQL repository
- Add authentication and authorization
- Create comprehensive unit and integration tests
- Add metrics and distributed tracing
- Deploy to Kubernetes
Resources
You now have a solid foundation for building scalable, maintainable microservices in Go! The patterns you've learned here will serve you well in production environments.
Happy coding! 🚀
Top comments (0)