Don't Export Private Types in Go

When it comes to Go programming, one common anti-pattern developers encounter is exporting private types. In this article, we will delve into the origins of this anti-pattern, why it should be avoided, and the potential problems it can cause.
— Estimated reading time: 8 minutes

Unexported Types in Go: Where It All Started

When transitioning to the world of Go from classical object-oriented languages like Java or PHP, many developers are accustomed to using classes and automatic object initialization when calling constructors.

However, structures in Go are not the same as classes and objects in other languages. They play a similar role, but not identical!
Learn more about our approach to web development, and we also recommend checking out our cases.

Problems in Exporting Private Types

The main issue with exporting private types is that it violates the principle of encapsulation.

The very term "unexported structure" suggests that it is for internal use within the package. It's needed so that code consumers work ONLY with exported types. This encapsulation allows hiding what doesn't need to be accessed externally! Elements that work in a complex manner and can change with new package versions. Therefore, doing this is akin to trying to make something labeled "inedible" edible. It's like hammering nails with a drill.

Writing Structures That Don't Require Initialization

Examples: sync.Mutex, time.Time, sync.WaitGroup, and others.

These will work correctly with default initialization. Named constructors can be used to initialize them to the desired state: time.Now(), time.Parse(). This is the ideal approach. However, this cannot be achieved in all cases.

Complex Domain Services May Require Deeper Initialization

For example, dependency injection (Config, Logger, Repo, NetClient, etc.). In this case, a named constructor and a structure with private fields are created, into which nothing can be written at the basic initialization. In this case, dependencies, configurations, and other aspects are checked in the constructor, which may return an error.

NewDomainService(c Config, d Dependencies) (*DomainService, error)

Such services always have methods in their signature that perform some domain function:

  • Start(ctx context.Context) error
  • Process(ctx context.Context) error
  • Execute(ctx context.Context) error
  • .....

If the service has not been properly initialized, they will return an error. In 99% of cases, such services can return various errors for dozens of reasons. So the argument "I'm too lazy to handle errors" is simply ineffective.

Handling Errors

This is done with just two lines, automatically generated by the IDE. And this allows you to immediately understand the weak points of your code, potential errors, and prompts you to think about what to do with them. Don’t worry, nothing bad will happen.

The consumer will handle it, and in case of incorrect initialization, the programmer will see an error at the module's test level, if, of course, they write them. In the worst case, everything will collapse when trying to run the application in the very first test run.

However, if such a problem (my service was not initialized) leads to "some scary critical things," such as the database being deleted, the disk being formatted, or bitcoins being transferred to a non-existent wallet. The problem is with you, not with whoever is using your code! Because such things should never happen!

If you guard against unknown issues with "naive consumers"; expect problems.

Namely:

  1. You won't be able to build a proper application architecture. Because you simply can't use this private type in the top-level layers. For example, if it's a repository or some other domain service, it needs to be described in the consumer's interface. Keep in mind that you cannot do that because it's a private type! You simply won't be able to type anything beyond its package. Therefore, DI won't work.
  2. If you want to pass it to the interface type of some consumer, you'll need the following:

    - ServiceLocator or ApplicationRegistry – this is a top-level architecture object that stores references to the main services. It must know how to store the initialization of this object.

    - IoC – here you will need to bind the implementation for the interface, which will be automatically injected during object building. Whether you do it manually, use Wire or FX, you still need to write, for example, an FX Show constructor for this type, and then bind it to the necessary interfaces. All this is impossible to do with private types.
  3. You won't be able to use GoDoc documentation because private types are not described there for external use. which is absolutely logical!
  4. 4. It contradicts the language's idiomatics and confuses other developers with a non-standard and incorrect approach.

Conclusion

Exporting private types in Go may seem convenient at first glance, but in practice, it can lead to serious problems and difficulties in development. Use private types only for internal use within your packages and adhere to the idiomatic approach to writing code in Go.

If you need consultation regarding Go development, or if you want to enhance your application, feel free to reach out, and we'll suggest the best solutions for your task.