In software engineering, decoupling is making two connected parts of your software not as connected, or not having the need to know any logic behind the other part. That concept can be used in many parts of a software architecture. For example in a microservice architecture, one service knows the API contract of the other microservices, but does not need to know any logic behind them.
In this article, though, I’ll talk about how to write decoupled Go code, which makes it more reusable, testable and more maintainable.
How do I make my code decoupled?
Always use interfaces. In Go, interface is the abstraction of a collection of method signatures (similar, but not equivalent to a Class in other languages).
For example, if my code has a database connection dependency:
type auditor struct {
db *sql.Connection
}
func NewAuditor(db *sql.Connection) auditor {
return auditor{ db: db }
}
How do I test this code? I cannot create and auditor
instance without passing a database connection. Also, if I change my database I would have to rewrite all my code.
To solve this problem, we use interfaces! Using interfaces helps with decoupling because any implementation of that interface can be used in it’s place.
For example:
type Auditor interface {}
type auditor struct {
db sql.Db // db interface
}
func NewAuditor(db sql.Db) Auditor {
return auditor { db: db }
}
Receiving an interface and also returning an interface, makes my code decoupled, more testable and easier to change. In this case the auditor does not need to know what kind of database I’m using or how it works. In a unit test environment, for example, I could use a mock that implements that interface and my test would only cover the logic that it really needs to test.
Creating interfaces
Creating interfaces on it’s own does not solve the problem, it has to be well thought to avoid dependency problems or other kinds of coupling. So, here are some things I have in mind while creating my interfaces:
Single responsibility principle: An interface must have a specific and very clear responsibility, and that should be very clear in the definition of the name and the methods. In the example below we have the Auditor
interface which deals with auditing.
type Auditor interface {
Create()
Get()
List()
Update()
Delete()
}
Be careful with single responsibility principle, though, as I have seen people create very specific interfaces with only one method, which makes hard to reuse and can create a dependency hell problem. See the example below:
type AuditCreator interface {
Create()
}
type AuditGetter interface {
Get()
}
type AuditUpdater interface {
Update()
}
In some cases, it’s good to have reading and writing responsibilities in different interfaces to guarantee that certain parts of the code do not have access to some of those functionalities.
See the example:
type AuditReader interface {
Get()
List()
}
type AuditWriter interface {
Create()
Update()
Delete()
}
type Auditor interface {
AuditReader
AuditWriter
}
Where should my interfaces be declared?
There are a lot of approaches for this:
-
Interfaces near their clients
This, in my opinion, is not a good approach, since it makes it hard to reuse interfaces and interfaces are all over the code, which makes it a little less organized.
-
Interfaces in a separate package
Although this approach is better than the first one, it seems unnecessary.
-
Interfaces near their implementation
I think this is the best approach. See Go’s internal usage, for example, if you wanted a
Reader
interface you would not declare it where you wanted to use it, or in a separate package, you would import theio.Reader
that the Go team declared near their implementation. That really helps reusing interfaces, and does not force you to add another import on your client.
Conclusion
Writing decoupled code is not as simple as it seems, but in this article I described some tips that I personally think help with that. A good thing I like about Go is the simplicity, so I always tend to use the simplest approach to solve my problems and improve my code.