Writing Clean Functions in Go with the Full Mapping Strategy

Writing Clean Functions in Go with the Full Mapping Strategy

Elliot Forbes Elliot Forbes ⏰ 6 Minutes 📅 Aug 8, 2023

Over the past few months, I’ve been heavily involved in building out new critical systems within a new team that is ultimately at the start of it’s Go development journey.

I was tasked with coming in, helping to deliver this new system and also to help build confidence within the team when they are making changes to this system and subsequently help with delivery timelines.

Now, I needed a way to make sure the system was as simple as possible, and a lot of time was spent decoupling tightly coupled components. One of the techniques I used to achieve this was using explicit input and output structs for all of the funtions and methods within the code.

The Theory

This approach could be considered an example of a full mapping strategy and it was something I picked up on after reading Tom Hombergs' book - Get Your Hands Dirty On Clean Architecture. Whilst the book uses Java in the code samples, all of the principles that he covers are also directly applicable to Go and I’d highly recommend picking this up!

Note: It’s important to note that this pattern shouldn’t typically be employed everywhere - Tom notes in his book that this approach does carry a lot of additional overhead to begin with. A point that I fully agree with!

Defining Explicit Input Structs

So let us dig in, and see how this strategy works within our Go applications.

Now, this technique employs defining explicit input structs for the functions/methods and being far more deliberate about what information is being passed around. We define an exact struct that only contains the fields that this particular function cares about.

Let’s see how this works in practice - let’s hypothetically say we have an invoice package that handles all invoice related use cases within our system. It might depend on an emailService to handle the underlying complexities with sending emails.

In this package, we want to be able to define a method that allows us to send customer invoices.

// We define an exported Opts function that
// takes in all of the fields that our method needs.
type SendCustomerInvoiceOpts struct {
	CustomerEmail   string
	ProductName     string
	ProductPrice    int
	ProductQuantity int
}

func (s *Service) SendCustomerInvoice(ctx context.Context, opts SendCustomerInvoiceOpts) error {
	s.logger.Info(ctx, "sending a customer invoice")

	// Notice how we've employed the same explicit Opts pattern for our `SendEmail`
	// method implemented the downstream package.
	err := s.emailService.SendEmail(ctx, email.SendEmailOpts{
		Title:           "Your Shiny Invoice",
		Recipient:       opts.CustomerEmail,
		ProductName:     opts.ProductName,
		ProductPrice:    opts.ProductPrice,
		ProductQuantity: opts.ProductQuantity,
	})
	if err != nil {
		return err
	}

	return nil
}

In the above code, we’ve defined the XOpts struct which defines the precise information that this particular method needs to function.

What would the alternative be here? Well, before this article helped to show you the light, you might have defined this function to look something like this:

// method arguments are domain models - `Customer` and `Product`
func (s *Service) SendCustomerInvoice(ctx context.Context, customer Customer, product Product) error {
    s.logger.Info(ctx, "sending a customer invoice")

    err := s.emailService.SendEmail(ctx, "Your Shiny Invoice", customer, product)
    if err != nil {
        return err
    }

    return nil
}

This doesn’t seem all that egregious, we aren’t necessarily breaking any big rules here. We might get caught by an annoying line-length linter if we add any more arguments here, but no real biggie.

However, let’s imagine that we want to update our Customer domain model. Perhaps it may have grown to a fairly significant size and as such we want to move the Email field into a sub-struct called ContactDetails.

Well, we suddenly need to walk through every function in our application and update any references to the Email field to now be customer.ContactDetails.Email. This can lead to fairly substantial diffs within our pull requests and the chances of missing critical things grows in size.

In this example alone, we’d have to walk through the chain, validate that nothing needs to change in SendCustomerInvoice and also make the appropriate changes in our implementation of SendEmail

Input Validation

Now, with this pattern, we can take things a step further and start to build fine-grained input validation on top of these XOpts structs fairly easily.


// We define an exported Opts function that
// takes in all of the fields that our method needs.
type SendCustomerInvoiceOpts struct {
	CustomerEmail   string `validate:"required,email"`
	ProductName     string `validate:"required"`
	ProductPrice    int    `validate:"required"`
	ProductQuantity int    `validate:"required"`
}

func (s *Service) SendCustomerInvoice(ctx context.Context, opts SendCustomerInvoiceOpts) error {
	s.logger.Info(ctx, "sending a customer invoice")

    validate = validator.New()
    err := validate.Struct(opts)
    if err != nil {
        s.logger.Error(ctx, "validation failed on send customer invoice call")
        return err
    }

	// Notice how we've employed the same explicit Opts pattern for our `SendEmail`
	// method implemented the downstream package.
	err := s.emailService.SendEmail(ctx, email.SendEmailOpts{
		Title:           "Your Shiny Invoice",
		Recipient:       opts.CustomerEmail,
		ProductName:     opts.ProductName,
		ProductPrice:    opts.ProductPrice,
		ProductQuantity: opts.ProductQuantity,
	})
	if err != nil {
		return err
	}

	return nil
}

This is a powerful pattern that could help prevent unwanted side-effects within your systems.

Improved Testing

Using this explicit argument pattern, we also happen to improve how we write tests. The general set up for each of our tests becomes far more succinct and focused on the task at hand.


// note this is all rough pseudocode - I
func TestSendCustomerInvoice(t *testing.T) {
	// set up our service
	// instantiate any mocks we might have in place
	emailService := email.New()
	invoiceService := invoice.New(emailService)
	t.Run("happy path - we can send an invoice successfully", func(t *testing.T) {
		// we don't have to fully generate a Customer and a Product struct here
		// we can instead keep our tests far more focused and to the point.
		opts := SendCustomerInvoiceOpts{
			CustomerEmail:   "elliot@example.org",
			ProductName:     "Premium Course Subscription", // subtle marketing there eh?
			ProductQuantity: 1,
			ProductPrice:    1299,
		}

		err := invoiceService.SendEmail(context.Background(), opts)
		assert.Nil(t, err)
	})

    t.Run("sad path - invalid customer email", func(t *testing.T){
        opts := SendCustomerInvoiceOpts{
            CustomerEmail:   "invalid.email",
            ProductName:     "Premium Course Subscription",
            ProductQuantity: 1,
            ProductPrice:    1299,
        }

        err := invoiceService.SendEmail(context.Background(), opts)
        assert.NotNil(t, err)
    })

	t.Run("sad path - missing price and quantity", func(t *testing.T) {
		opts := SendCustomerInvoiceOpts{
			CustomerEmail:   "invalid.email",
			ProductName:     "Premium Course Subscription",
		}

		err := invoiceService.SendEmail(context.Background(), opts)
		assert.NotNil(t, err)
        // hopefully you'll sprinkle in some stronger assertions here in the real world
	})
}

Again, notice how we are saving ourselves the trouble of having to update all of these tests if we used the less explicit approach of passing Customer and Product.

Recap of the benefits

Following this fully-mapped approach thus gives us a few added benefits:

  • A far more explicit approach to defining what your function needs.
  • Far less changes required across your system if your underlying domain model changes.
  • The ability to add stronger validation between the various calls within our system.

It does come at the expense of additional initial overhead when defining these functions, but I feel that’s a price worth paying.

Thanks for reading and if you found this interesting, or have any thoughts yourself, I’d love to hear them in the comments section below!