DEV Community

James Eastham
James Eastham

Posted on

Refactoring with Clean Architecture - The magic of well-designed software

This week has been a pretty crazy one, with an AWS exam and two weddings in three days (one of which I was the best man) there hasn't been much mental capacity for Golang.

So this post takes on a slightly shorter and more high-level discussion around software architecture and the huge benefits that come from doing it 'right'.

Don't worry, there are still some lines of code.

Doing it right

Much like a lot of things in life, I'm not the kind of person who believes his opinion is the only one and everyone should do the same way.

Part of the fun in life is understanding other people's points of view, and putting together the best of different viewpoints.

That's not to say I don't have opinions on software design though. Far from it.

I'm a huge advocate of Uncle Bob Martin 'Clean Architecture' style of coding. Loosely related to Jeffrey Palermo's Onion Architecture.

The basic rule being, that each layer of a software system should have no dependency on a layer outside of it.

The core of the onion would be pure business entities. Coupled with Domain Driven Design, this gives you a central code base that is extremely well matched to the domain itself.

No mention of SqlConnection's or HTTP calls. Pure, well-structured code.

Outside of that, there are domain services or interactors. These are objects that have the sole responsibility of managing your domain objects. Business logic is likely to sit in this layer.

Taking an example from the team-service repository in my football league manager app git repo.

// CreateTeamRequest holds a reference to new team data.
type CreateTeamRequest struct {
    Name string
}

// CreateTeamResponse holds a reference to new team data.
type CreateTeamResponse struct {
    ID     string
    Name   string
    Errors []string
}

// CreateTeam creates a new team in the database.
func (interactor *TeamInteractor) CreateTeam(team *CreateTeamRequest) (*CreateTeamResponse, error) {
    if len(team.Name) == 0 {
        interactor.Logger.Log("Team name cannot be empty")
        var response = &CreateTeamResponse{
            ID:     "",
            Name:   team.Name,
            Errors: make([]string, 1),
        }

        response.Errors[0] = "Team name cannot be empty"

        return response, errors.New("Team name cannot be empty")
    }

    newTeam := &domain.Team{
        Name: team.Name,
    }

    createdTeamID := interactor.TeamRepository.Store(newTeam)

    interactor.EventHandler.Publish("leaguemanager-info-newteamcreated", TeamCreatedEvent{
        TeamID:   createdTeamID,
        TeamName: team.Name,
    })

    return &CreateTeamResponse{
        ID:   createdTeamID,
        Name: newTeam.Name,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The CreateTeam method of the team interactor holds all the logic for creating a new team in the database. It takes a CreateTeamRequest as it's input, and returns a CreateTeamResponse.

You'll notice the code references interactor.EventHandler and interactor.TeamRepository. These are interfaces that will, at some stage, be implemented with actual event handling and data storage code.

As far as the business logic is concerned though, nothing changes.

Going back to the title of the article, refactoring is made so much easier following this model.

Any changes to the data storage provider have no effect AT ALL on the business logic itself. Conversely, business logic can be changed freely with ease.

If I wanted to change exactly when the data was persisted to the repository, I could move the call to TeamRepository.Store and not worry about the details about how it actually happens.

One of the best takeaways from Bob Martin's book on Clean Architecture is regarding the detail. The details (Database provider, event bus etc.) should be left to as late in the development process as possible.

Often, people want to jump in with the database, then spend 2 months getting bogged down with schema.

The alternative, write your code first abstracting the storage layer. Then add your database once you know more about the data you will be stored in it.

Event handling - An example

Currently, the code for the event handler of the team service uses Amazon SNS and looks something like the below.

package infrastructure

import (
    "errors"
    "fmt"
    "strings"
    "team-service/domain"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/credentials"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/sns"
)

// ErrTopicNotFound is returned when the requested topic is not found.
var ErrTopicNotFound = errors.New("Specified topic not found")

// AmazonSnsEventBus is an event bus implementation using Amaazon SQS.
type AmazonSnsEventBus struct {
    svc       *sns.SNS
    availableTopics []string
}

// NewAmazonSnsEventBus creates a instance of the AmazonSnsEventBus.
func NewAmazonSnsEventBus() *AmazonSnsEventBus {
    // Initialize a session that the SDK will use to load
    // credentials from the shared credentials file ~/.aws/credentials
    // and region from the shared configuration file ~/.aws/config.
    sess := session.Must(session.NewSession(&aws.Config{
        Region:      aws.String("eu-west-1"),
        Credentials: credentials.NewSharedCredentials("", "league-manager-sqs"),
    }))

    svc := sns.New(sess)

    availableTopics, _ := svc.ListTopics(nil)

    availableTopicArns := make([]string, len(availableTopics.Topics))

    for i, t := range availableTopics.Topics {
        availableTopicArns[i] = *t.TopicArn
    }

    return &AmazonSnsEventBus{
        svc:       svc,
        availableTopics: availableTopicArns,
    }
}

// Publish sends a new message to the event bus.
func (ev AmazonSnsEventBus) Publish(publishTo string, evt domain.Event) error {
    requiredTopicArn := ""

    for _, t := range ev.availableTopics {
        if strings.Contains(t, publishTo) {
            requiredTopicArn = t
        }
    }

    if len(requiredTopicArn) > 0 {

        result, err := ev.svc.Publish(&sns.PublishInput{
            Message: aws.String(string(evt.AsEvent())),
            TopicArn:    aws.String(requiredTopicArn),
        })

        if err != nil {
            fmt.Println("Error", err)
        }

        fmt.Println("Event published: ", *result.MessageId)

        return err
    }

    return ErrTopicNotFound
}
Enter fullscreen mode Exit fullscreen mode

Since learning more about AWS services, I've actually discovered that Amazon Kinesis is a better tool than SNS for my exact use case.

Instead of needing to pick my way through the entire code base looking for references to Amazon SNS. I just need to write a new implementation of the event handler interface that uses Kinesis.

On application startup, I just decide which event handler to use.

// teamInteractor.EventHandler = new(infrastructure.MockEventBus)
// teamInteractor.EventHandler = infrastructure.NewAmazonSnsEventBus()
teamInteractor.EventHandler = infrastructure.NewAmazonKinesisEventBus()
Enter fullscreen mode Exit fullscreen mode

No changes to business logic. No changes to domain objects.

One single line of code changed, and all that's changed is the infrastructure.

The chances of a bug being introduced are almost non-existent (granted there may be bugs in the new code that gets written).

So whilst I'm not saying this is the only way to build well-designed software, and it certainly does have flaws. I hope it has shown that putting time into the design pays out massive dividends in the future.

Latest comments (0)