Writing Clean Functions in Go with the Full Mapping Strategy
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!