DEV Community

Sylvester Asare Sarpong
Sylvester Asare Sarpong

Posted on

gRPC, Dependency Injection with Uber Fx, and Hexagonal Architecture in Go

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fast: Uses HTTP/2 with binary serialization (Protocol Buffers)
  2. Type-safe: Strongly-typed contracts prevent runtime errors
  3. Language-agnostic: Write services in any language gRPC supports
  4. Bidirectional streaming: Supports client, server, and bidirectional streaming
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  • We define a service called Greeter
  • It has an RPC method SayHello
  • SayHello takes a HelloRequest message with a name field (type: string)
  • It returns a HelloReply message with a message field (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;
}
Enter fullscreen mode Exit fullscreen mode

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: a and b
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4. Install Protocol Buffers Compiler (protoc)

macOS (using Homebrew):

brew install protobuf
Enter fullscreen mode Exit fullscreen mode

Linux (Debian/Ubuntu):

sudo apt install -y protobuf-compiler
Enter fullscreen mode Exit fullscreen mode

Verify installation:

protoc --version
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add Go bin to your PATH:

export PATH="$PATH:$(go env GOPATH)/bin"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (.proto files)
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Usage:

# Generate protobuf code
task proto-gen

# Run the server
task run

# Build the binary
task build
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Generate the Go code:

task proto-gen
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Understanding the code:

If you're familiar with creating HTTP servers in Go, this should look similar:

  1. Create a TCP listener on port 50051
  2. Create a gRPC server instance
  3. Register services (we'll do this next)
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run your server:

task run
# or
go run cmd/server/main.go
Enter fullscreen mode Exit fullscreen mode

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

  1. Create a new request and select gRPC as the protocol
  2. Enter the server URL: localhost:50051
  3. Import your proto file: Click "Select a method" → "Import .proto file" → Navigate to pkg/protos/calculator.proto
  4. Select a method: Choose Calculator.Add, Calculator.Multiply, or Calculator.Divide

Making a Request

For the Add method:

Request:

{
  "a": 5,
  "b": 3
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "result": 8
}
Enter fullscreen mode Exit fullscreen mode

Try the same for Multiply and Divide to verify all methods work correctly.

What Just Happened?

  1. Postman read the protobuf contract to understand available methods and their signatures
  2. It constructed a valid gRPC request with the data you provided
  3. Your server received the request, called the appropriate method, and returned the result
  4. 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!
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Now you can plug in:
    • PostgresRepo for production
    • MySQLRepo for a different deployment
    • InMemoryRepo for tests
    • MockRepo for 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()
}
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

Fx automatically:

  1. Calls constructors in the correct order
  2. Passes dependencies to each constructor
  3. Invokes startup functions with all dependencies satisfied
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        },
    })
}
Enter fullscreen mode Exit fullscreen mode

Understanding the code:

  1. fx.Provide: Declares constructors that Fx should call
  2. fx.Invoke: Declares functions that should run at startup
  3. fx.Lifecycle: Manages application startup and shutdown
  4. OnStart hook: Starts the gRPC server in a goroutine
  5. 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
Enter fullscreen mode Exit fullscreen mode

What's happening:

  1. 🔵 Fx builds all dependencies in the correct order
  2. 🟢 Ports (interfaces) keep everything decoupled
  3. 🟠 gRPC handler only knows about the port, not the implementation
  4. 🟣 Repository implements a port, can be swapped easily
  5. 🔵 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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test with Postman

  1. Create a new gRPC request
  2. Connect to localhost:50051
  3. Import pkg/protos/calculator.proto
  4. Test each method:

Calculator.Add:

Request:  {"a": 10, "b": 5}
Response: {"result": 15}
Enter fullscreen mode Exit fullscreen mode

Calculator.Multiply:

Request:  {"a": 7, "b": 8}
Response: {"result": 56}
Enter fullscreen mode Exit fullscreen mode

Calculator.Divide:

Request:  {"a": 20, "b": 4}
Response: {"result": 5}

Request:  {"a": 10, "b": 0}
Error:    "cannot divide by zero"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
    }
}
Enter fullscreen mode Exit fullscreen mode

Middleware

Add interceptors for authentication, logging, etc.:

func NewGRPCServer(logger *zap.Logger) *grpc.Server {
    return grpc.NewServer(
        grpc.ChainUnaryInterceptor(
            LoggingInterceptor(logger),
            AuthInterceptor(),
        ),
    )
}
Enter fullscreen mode Exit fullscreen mode

Health Checks

Implement gRPC health checks:

import "google/health/v1/health.proto";
Enter fullscreen mode Exit fullscreen mode

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

  1. gRPC provides fast, type-safe communication between services using Protocol Buffers and HTTP/2
  2. Dependency Injection decouples components and makes testing straightforward
  3. Hexagonal Architecture keeps business logic pure and independent of delivery mechanisms
  4. 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)