Caught off guard: Should you declare more than one “class” within a single file (for Go)?
“Why do we declare more than one “class” in a single .go file?”
“That is a very interesting question”, I thought to myself. As I scrambled to find an answer to this very intriguing question, a phrase appeared up in my mind — Go packages.
Nevertheless, I have had my qualms with some code structuring standards of the language. Variable naming for acronyms was one that I discussed with my colleagues with passion — it stuck out from the norm from all the other languages — e.g.
simpleHTTPServerID instead of
simpleHttpServerId. That said, the benefits for capitalizing acronyms entirely were still, in a way, reasonable too.
Back to the present day and time, cue to my surprise when I was asked the same question in the title. To clarify, I was asked this question by someone who was new to Go, and what was compared here was the declaration of a custom type in Go, versus classes for languages like Ruby.
Given that the constructs on both sides are similar, one would associate custom types in Go to classes in other languages. It made sense that code structures for a single class per file in other languages should rightfully apply to Go as well.
Except that it didn’t.
By convention, especially when looking at native Go libraries and open-source repositories, having multiple types defined in a single “.go” file was actually considered “ok”.
So what gives? I decided to dive in deeper to find out.
Examining other languages
Before we talk about Go and the whys to the question, I thought it would be nice to explore other languages first and reexamine our understanding here— Why separate classes to different files?
The first clue that I found for Java, came from a document about file organization from Oracle:
From the documentation, it highlights the standard that a single Java file should only contain one public class (though multiple private classes can be defined and used internally). This is further enforced by one documentation I found for a version of the Java compiler, where the name of one outer public class must match the name of the file it is in, making it necessary as a code structure by the design of the language.
So far, the documentation does suggest that there is some restriction for a single public class for Java per file. That said, there are also ways around it:
Nested classes provide an alternative for declaring only one class per file. One could essentially declare a bunch of nested static classes and use the outer class as an entry point.
For Ruby, there isn’t an elaborate explanation of the restrictions in this matter. If anything, there are no restrictions for declaring multiple classes per .rb file.
However, if you are using the popular Ruby framework, Ruby on Rails, you’ll get a different message at the onset — where separating and naming your Ruby files is encouraged, for deriving autoloading benefits when using the framework.
While vanilla Ruby does not have any restrictions for classes like in Java, the style guide also suggests the same guidelines as per Rails' documentation above.
Hence, differing from Java, multiple outer classes in a Ruby file is not restricted. However, a single class per file here works best for frameworks and is also listed as a best practice.
The above observations of the different languages are mostly from the documentation that can be found online, but what about the developer community?
Browsing through pages and pages of StackOverflow and Reddit on this topic has started to give me a clearer picture. While languages like Java can step in and enforce this practice, the recommended practice of a single class per file for any language is an intuition backed by most of the community. And it goes back to one core principle that we learned in school: “High Cohesion, Low Coupling.”
In general, here’s what it means in the context of classes in these programming languages:
High Cohesion — functionality, and logic that are closely related should be placed together, for example, in the same class.
Low Coupling — low degree of dependency between classes, such as a single interface for interaction, instead of interacting with the inner workings and workflows of other classes and modules.
All in all, high cohesion encourages a single class to represent highly associated constructs in code, while low coupling discourages the declaration of shared context between these classes, making separation of classes in different files an intuitive choice.
Go and its conventions here?
We’ve examined different programming languages, and understood why there is an inherent intuition to separate classes into different files. What about Go?
Firstly, we should consider if custom types in Go are equivalent to “classes” in other languages? Let’s look at an earlier image:
There are some similarities between the
Page type in Go and
Page class in Ruby, mostly because they have a similar set of getter and setter methods.
However if we stretch this to include traditional “class” attributes like inheritance, you’ll find very quickly that in Go, there is no such thing as inheritance. The closest solution in Go is to embed types into other types (sometimes even multiple embedded types at once), which is composition rather than inheritance.
So … there are no classes in Go? But surely it would still be beneficial to separate Go’s custom types and methods into their own files?
The answer here is … Go packages
For Go, unlike other languages, there’s no such thing as importing a single file. A short statement from the code organization documentation, at Go’s main site, explains the support for multiple types in a package.
As it turns out, in Go, we declare and import entire sets of “.go” files via these “packages”. Take a look at a sample project image I have below:
All these green rectangles are essentially directories that reference their own package. For example, the
core directory represents the main package that consists of all the code from its four files, and I would look at importing the package with
repository.com/core instead of
In other words, every single file in the same package can be considered as bundled together. However, this doesn’t necessarily mean that we should break all sorts of principles and create a bunch of unrelated custom types in the same files and package, for the following reasons:
- A package should reflect a single concept/functionality (high cohesion, low coupling)
- A package can contain multiple types if they represent parts of the concept or used internally within the concept. Types that are private constructs of the package should remain unexported, unless necessary. Exported types and methods expose the package as different parts of its interface.
A really great example here is to look at Go native libraries to understand the extent of cohesion and coupling of packages — for example, SQL
In the example above from the SQL library’s sql.go,
TxOptions exist in the same package and file, for performing a transaction within a database connection. The context could be short enough the make referencing between these two types a simpler task. While decoupled into different types, they are used “cohesively” within the same
Still, if separating custom types and receiver methods into its own files makes the code more readable in the package (especially if the code for a single type is very long), go ahead and do it!
So what gives?
Taking a step backward, we see that all these languages do embrace the same “High cohesion, low coupling” principle. However, the principle exists in Go at a different level. In Go, it doesn’t necessitate the declaration of an individual “class” in its own file, simply because the language works differently — utilizing composition over inheritance, and custom types that do not represent the traditional “class” construct as witnessed in other languages.
Instead, Go looks at grouping a distinct concept in a package of files, rather than an individual “class” concept in its own file. This brings in the cohesive association of types and methods belonging to the same concept. It also brings in more safeguards — like having unexported “private” types that can only be used in the same package, making the model more resilient to unnecessary coupling, as unexported “private” logic is not exposed and cannot be imported.
So, I was caught off guard, but taking a few steps back, I was able to get a better appreciation of the language. I hope that you were able to pick up some new perspectives here and let me know if there are other reasons why Go doesn’t work the same way here as other languages.