Building a Blog Backend with Hexagonal Architecture in Go

What I learned using hexagonal architecture to build my personal website backend

Preamble

Writing a technical blog post is actually not as easy as I thought it would be. I feel you are expected to take a stance and kind of get out of the woods with some strong statement, otherwise why write a blog post?
Still, I feel this is not a good idea. I have my experience, a few years, I have seen businesses, but I am far from knowing all the contexts of every software engineer. That's why I find it so weird to see some global comments on Reddit about software engineering, since all the decisions you make are always depending on the context.

I am really not fond of gregariousness. I am not fond of cults either! So I won't lecture you in this article about hexagonal architecture, how good it is, or how you should use it, otherwise "you are coding a 'very bad application'1". I am really not behind any church that proposes automatic answers to architecture questions. Everything, always, comes with a trade-off.

I think the hexagonal architecture pattern is good at some point, but you probably don't need it right away or even at all, especially if you have one port, one adapter. Adding abstraction also has a cost, so use this pattern cautiously when every CPU cycle matters.

That being said, I am a strong believer in the compounding effect in software.

I believe that certain things should be done as early as possible to see their effects impact your project or product as soon as possible. For example: a proper CI/CD, a pre-commit hook, tests.

Choosing Hexagonal architecture can be part of this, and it's probably better to switch sooner rather than later when it is needed. In my humble opinion, it rapidly compounds and helps you structure your code and think in terms of contracts. I am not completely sold on "just in time" architecture because the sooner you know you need an architectural pattern, the cheaper your refactoring will be. In that sense I am a strong believer in the Pragmatic Programmer "Easier to Change" principle.

What are we going to do?

In this blog post I'll gloss over how I used hexagonal architecture in Go to code the backend of this website.

Why did I choose this pattern for a personal website? Honestly, it started as a way to structure my code properly and make integration testing easier. But the real tipping point came when I wanted to switch from PostgreSQL to Valkey for my session tokens. That's when hexagonal architecture proved its worth. I just swapped the adapter, and the core domain remained untouched. That is the moment when the upfront investment was validated.

First, let's begin with what we want to do. Since we are publishing blog posts, we need:

  • An HTTP server with a route to create an article
  • An article representation, somehow
  • A database to store our articles
  • We also need to validate the input sent to our HTTP handler

That's already a lot to do, and I think we will focus on one route for this article. The code is available here and I encourage you to dive into the code if you want more examples than what this post shows.

Let's talk structure

When it comes to hexagonal architecture, you have to split things in two:

  • The domain of your application, also called the core:
    This is where the domain definition lies. In our case, the user and the article. This is also where we define what a port is. Think of ports as contracts: they define what should be implemented so that the domain and the external world (database, etc.) can interact.

  • The infrastructure of your application:
    This is where you define the actual implementation of your ports! These are called adapters.

Since a diagram is better than a long explanation, here's a folder tree:

.
├── cmd
├── internal
│   ├── core
│   │   ├── domain -> where the domain definition and logic lies
│   │   ├── ports -> the contract to interact with the domain
│   │   └── validation -> the domain validation
│   └── infrastructure
│       ├── adapters -> adapters for the ports
│       ├── dto_validation -> validation of inputs
│       └── http -> the http server implementation
├── pkg
│   └── utils
├── sql
│   ├── queries -> sqlc queries
│   └── schemas -> migrations schema of the database
└── tests -> our integration tests

Step-by-step implementation

Step 1: Define the domain objects

The first thing we will implement is our domain objects and our contracts.

We want blog posts, but what is needed for a blog post is the question you should ask yourself first.
I'll go with:

  • I want to know:
    • Its ID
    • A slug to fetch it from the client call
    • A creation datetime
    • Its content
    • Is it published? And when was it published?
    • Is it deleted? And when? (for soft delete mechanism)

Let's start with that! In Go, that would mean:

// internal/app/core/domain/articles.go
package domain

import "time"

type Article struct {
	ID          int32
	Title       string
	Slug        string
	Content     string
	CreatedAt   time.Time
	PublishedAt time.Time
	DeletedAt   time.Time
	IsPublished bool
}

Step 2: Define the ports (contracts)

Now we need to define the port for our article repository. Remember, a port is a contract: an interface that defines what operations should be available.

// internal/app/core/ports/article_repo.go
package ports

import (
	"context"
	"personal_website/internal/app/core/domain"
)

type ArticleRepository interface {
	CreateArticle(ctx context.Context, article domain.Article) error
	GetArticleByID(ctx context.Context, id int32) (domain.Article, error)
	GetArticleBySlug(ctx context.Context, slug string) (domain.Article, error)
	UpdateArticle(ctx context.Context, article domain.Article) error
	PublishArticle(ctx context.Context, id int32) error
	UnpublishArticle(ctx context.Context, id int32) error
	ListArticles(ctx context.Context) ([]domain.Article, error)
	SoftDeleteArticle(ctx context.Context, id int32) error
}

Step 3: Implement the adapter (Infrastructure layer)

The adapter is the concrete implementation of the port. This is where we interact with the actual database using PostgreSQL and sqlc. Notice how this lives in internal/infrastructure, outside the core domain.

// internal/infrastructure/adapters/repository/postgres/article_adapter.go
package postgres_adapter

import (
	"context"
	"database/sql"
	"errors"
	"personal_website/internal/app/core/domain"
	"personal_website/internal/infrastructure/adapters/repository/postgres/sqlc"
	"github.com/lib/pq"
)

type articleAdapter struct {
	queries *sqlc.Queries  // sqlc generated queries
}

func NewArticleAdapter(queries *sqlc.Queries) *articleAdapter {
	return &articleAdapter{
		queries: queries,
	}
}

// CreateArticle implements the ports.ArticleRepository interface
func (a *articleAdapter) CreateArticle(ctx context.Context, article domain.Article) error {
	err := a.queries.CreateArticle(ctx, sqlc.CreateArticleParams{
		Title:   article.Title,
		Slug:    article.Slug,
		Content: article.Content,
	})
	if err != nil {
		var pgErr *pq.Error
		if errors.As(err, &pgErr) {
			switch pgErr.Code {
			case "23505": // unique_violation - slug already exists
				return domain.ErrArticleAlreadyExists
			}
		}
		return domain.NewInternalError(err)
	}
	return nil
}

// GetArticleBySlug implements the ports.ArticleRepository interface
func (a *articleAdapter) GetArticleBySlug(ctx context.Context, slug string) (domain.Article, error) {
	row, err := a.queries.GetArticleBySlug(ctx, slug)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Article{}, domain.ErrArticleNotFound
		}
		return domain.Article{}, domain.NewInternalError(err)
	}
	// Convert sqlc row to domain Article
	return a.sqlcRowToArticle(row.ID, row.Title, row.Slug, row.Content,
		row.CreatedAt, row.PublishedAt, row.IsPublished, sql.NullTime{}, sql.NullBool{}), nil
}

// ... other methods implementing ArticleRepository interface

Key point here: The adapter imports the domain package (core → infrastructure dependency is OK), but the domain never imports infrastructure. This is the dependency inversion principle in action.

Step 4: Create DTOs for validation

We need Data Transfer Objects (DTOs) to validate incoming HTTP requests before they reach our domain.

// internal/infrastructure/http/dto/article.go
package dto

type ArticleRequest struct {
	Title   string `json:"title" validate:"required,min=5,max=200"`
	Slug    string `json:"slug" validate:"required,min=3,max=100,alphanum_hyphen"`
	Content string `json:"content" validate:"required,min=50,max=80000"`
}

type ArticleResponse struct {
	ID           int32   `json:"id"`
	Title        string  `json:"title"`
	Slug         string  `json:"slug"`
	Content      string  `json:"content"`
	Created_at   *string `json:"created_at"`
	Published_at *string `json:"published_at"`
	IsPublished  bool    `json:"is_published"`
}

Note: alphanum_hyphen is a custom validator defined in pkg/validation/validators.go that ensures slugs contain only alphanumeric characters and hyphens.

Step 5: Create HTTP handlers (Infrastructure layer)

The HTTP handlers live in the infrastructure layer and use the ports (interfaces), not concrete implementations. Notice how h.datastore.ArticleRepo() returns the port interface, the handler doesn't know or care if it's PostgreSQL, MySQL, or an in-memory store.

// internal/infrastructure/http/handlers/article.go
package handlers

import (
	"net/http"
	"personal_website/internal/infrastructure/http/dto"
	"personal_website/internal/infrastructure/http/mappers"
	"personal_website/pkg/utils"
)

func (h *Handler) CreateArticle(w http.ResponseWriter, r *http.Request) {
	// 1. Parse and validate the HTTP request
	var dtoArticle dto.ArticleRequest
	err := utils.ReadJSON(w, r, &dtoArticle)
	if err != nil {
		h.errorResponder.BadRequestResponse(w, r, err)
		return
	}

	// 2. Validate DTO using validation tags
	if !h.validateDTO(w, r, dtoArticle, "create article") {
		return
	}

	// 3. Map DTO to domain object
	ctx := r.Context()
	domainArticle := mappers.ArticleRequestToDomain(dtoArticle)

	// 4. Call the port method - we don't know what adapter is behind it!
	err = h.datastore.ArticleRepo().CreateArticle(ctx, domainArticle)
	if err != nil {
		h.HandleDomainError(w, r, err)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

The Handler struct holds references to ports, not adapters:

// internal/infrastructure/http/handlers/handlers.go
type Handler struct {
	config         *config.AppConfig
	logger         *slog.Logger
	datastore      ports.Datastore           // Port, not concrete implementation!
	emailService   ports.EmailService        // Port, not concrete implementation!
	resumeService  ports.ResumeService       // Port, not concrete implementation!
	userService    ports.UserService         // Port, not concrete implementation!
	errorResponder *utils.ErrorResponder
	telemetry      *telemetry.Telemetry
}

Step 6: Wire everything together (The missing piece!)

This is where hexagonal architecture "clicks." Here's how all the layers connect in cmd/app/app.go:

// cmd/app/app.go
func StartApp(cfg *config.Config) {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	// 1. Initialize concrete database adapters
	pgDatabase, err := postgres_adapter.NewDatabase(&cfg.Postgres)
	if err != nil {
		logger.Error("Failed to initialize database", "error", err)
		os.Exit(1)
	}
	defer pgDatabase.Close()

	vkDatabase, err := valkey_adapter.NewDatabase(&cfg.Valkey)
	if err != nil {
		logger.Error("Failed to initialize valkey", "error", err)
		os.Exit(1)
	}
	defer vkDatabase.Close()

	// 2. Create the datastore that aggregates both databases
	// This implements the ports.Datastore interface
	datastore := datastore_adapter.NewDatastore(pgDatabase, vkDatabase)

	// 3. Initialize the server with the datastore PORT (not the concrete adapters!)
	server, err := NewServer(ServerDeps{
		Logger:        logger,
		Config:        cfg,
		Datastore:     datastore,  // Injecting the port interface
		EmailSender:   email_sender.NewEmailSender(),
		ResumeService: resumeService,
		Telemetry:     telemetryInstance,
	})

	// 4. Start the server
	server.Serve(ctx)
}

The Datastore adapter aggregates repositories:

// internal/infrastructure/adapters/repository/datastore/datastore.go
type Datastore struct {
	postgresDB ports.PostgresDatabase
	valkeyDB   ports.ValkeyDatabase
}

func NewDatastore(postgresDB ports.PostgresDatabase, valkeyDB ports.ValkeyDatabase) *Datastore {
	return &Datastore{
		postgresDB: postgresDB,
		valkeyDB:   valkeyDB,
	}
}

// ArticleRepo returns the PostgreSQL article repository
func (d *Datastore) ArticleRepo() ports.ArticleRepository {
	return d.postgresDB.ArticleRepo()
}

// SessionRepo returns the Valkey session repository
// This is the adapter I swapped from PostgreSQL to Valkey!
func (d *Datastore) SessionRepo() ports.SessionRepository {
	return d.valkeyDB.SessionRepo()
}

Here's the dependency flow:

  1. main.go → initializes concrete adapters (PostgreSQL, Valkey)
  2. Adapters are wrapped in Datastore which implements ports.Datastore
  3. Datastore port is injected into HTTP handlers
  4. Handlers call methods on the port interface
  5. At runtime, the actual adapter (PostgreSQL/Valkey) executes the operation

This is the magic: When I wanted to switch session storage from PostgreSQL to Valkey, I only changed the SessionRepo() method in the Datastore adapter. The handlers, domain, and everything else stayed untouched.

Visual flow of a request:

HTTP Request (POST /articles)
         ↓
[Infrastructure Layer: Handler]
  - Validates DTO
  - Maps to domain.Article
  - Calls h.datastore.ArticleRepo().CreateArticle()
         ↓
[Core Layer: Port Interface]
  - ports.ArticleRepository interface method
         ↓
[Infrastructure Layer: Adapter]
  - articleAdapter.CreateArticle()
  - Executes SQL via sqlc
         ↓
PostgreSQL Database

The handler only knows about the ports.ArticleRepository interface. It has no idea there's a PostgreSQL database behind it. This is dependency inversion in practice.

Key takeaways

  1. Separation of concerns: Domain logic stays in internal/app/core/domain, ports define contracts in internal/app/core/ports, and infrastructure implementations live in internal/infrastructure.

  2. Dependency inversion: The HTTP handlers and domain depend on abstractions (ports/interfaces), not concrete implementations.

  3. Testability: You can easily mock the ports for unit testing without needing a real database.

  4. Flexibility: Want to switch from PostgreSQL to MySQL? Just create a new adapter that implements the same port interface!

Final thoughts: Was it worth it?

After building this project, I can say the pattern was worth it for my use case. Once you get accustomed to the gymnastic of synchronizing your ports and adapters, it becomes second nature. Yes, it's a bit of a gymnastic since you're constantly ensuring your interfaces align with your implementations but it's a manageable one.

The gymnastic isn't just about keeping ports and adapters in sync, though. It's also about figuring out where responsibilities belong. For example, I'm still not completely sure I have the perfect setup for error handling: my error responder sits in pkg and is responsible for rendering DTO errors, while domain errors are handled elsewhere. These kinds of boundary decisions take time to figure out, and honestly, I'm still iterating on some of them. The pattern gives you structure, but it doesn't automatically answer every "where does this go?" question.

That said, I have a opinion to share: I would not recommend this architectural pattern with dynamically typed languages. The compile-time safety that Go provides is more than a nice to have. Without it, you lose one of the main benefits: the compiler tells you immediately when your adapter doesn't fulfill the port contract. In dynamically typed languages, these mismatches are only discovered at runtime, or you rely on a linter or type checker, which is optional and only effective if consistently used.

For my personal website backend, hexagonal architecture hit the sweet spot between structure and pragmatism. Your mileage may vary, and that's okay.

A footnote extension for marked

  1. this linkedin conversation