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.
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:
- 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.
- 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. - You won't be able to use GoDoc documentation because private types are not described there for external use. which is absolutely logical!
- 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.