The One with the Directory Structure and Manual Wiring

In Go
4 min read

One of the things I found hardest initially about learning Go, was how to lay out my files on disk. There are a couple gotchas compared to other languages, like the possibility of cyclic dependencies preventing compilation, and the fact that package names are often seen in your actual code (not just the import statements).

I'm going to briefly outline how I've recently started to lay out projects. There's nothing really all that novel about it, but it's been working quite well for me in a few projects; even projects that serve vastly different purposes. Using a fake project called "hodor", here's what the core parts might look like:

$ tree hodor/ -F
hodor/
├── cmd/
│   └── hodor/
│       └── main.go
└── internal/
    ├── config.go
    └── resolver.go

3 directories, 3 files

Pretty straightforward right off the bat. At the top level, it starts with just 2 folders. cmd/ and internal/. Both of these names are quite commonly used in Go projects. (In fact, they're mentioned in places like here too).

  • cmd/: Contains all of the main packages in separate subdirectories. Each subdirectory name will then be built to it's own binary. You can use something like go install ./cmd/... to install all of them at once.
  • internal is a bit of a special package name that can only be imported by packages on the same level or below. In this case, that means any package within this project can import from the internal package.

I might also make a pkg/ folder, that's another quite conventional thing to do. The pkg/ folder would contain code I expect this project to export for other users to import. A good example of where you might do this is with a gRPC server; you might want to make the client and generated types available for other projects to consume.

Moving onto the actual files, we've already covered main.go, which was pretty self-explanatory anyway. We're just left with internal/config.go and internal/resolver.go. I chose to place these in the internal package because I feel like internal is also a good way to say "this is something that belongs to the project's core", sort of like if I were to make another hodor package, this way I just avoid it being hodor/internal/hodor. Another thing I like about placing those files in the internal package is that it can help prevent you from using the code in config.go and resolver.go in places that you shouldn't be...

Files

internal/config.go

This one will come as no surprise, it's just configuration. What I normally do it put a top-level struct that ties together other Config structs from other packages in my application. I might end up with something like this:

package internal

import (
    "github.com/seeruk/hodor/internal/http"
    "github.com/seeruk/hodor/internal/logging"
    "github.com/seeruk/hodor/internal/rpc"
)

// Config ties together all other application configuration types.
type Config struct {
    HTTP    http.Config
    Logging logging.Config
    RPC     rpc.Config
}

From there, I might also include some functions to load the configuration from a file, or from the environment, or both. You could use something like Viper if you wanted, or just stack up things that can unmarshal from somewhere onto your struct. If you need to add struct tags, then you can do that with ease too.

internal/resolver.go

I'm still not quite sure about the name of this thing. I nearly called it a factory at first. Everything I've come up with has felt a bit "Java". I wanted to solve a few specific problems:

  • I wanted to manually wire up dependencies. Having joined projects that used reflection-based DI like facebookgo/inject before I really wanted to avoid using any magic. I also didn't want a huge main.go, or to be hard-coding constructors into places that I shouldn't be that would make testing more difficult.
  • I wanted to be able to lazily resolve dependencies. I didn't like the idea of always constructing everything if I didn't need to.
  • I wanted to be able to share the same instance of something without globals. That was another facet of using facebookgo/inject that I wanted to be done with.
  • I wanted to be able to take advantage of dependency injection via constructors exclusively. This was important, because I wanted the compiler to be able to tell me if something had changed, and this is a great way to accomplish that.
  • I wanted to make sure I was also avoiding service location. I didn't want to fall into other bad habits to try make things seem easier at first.

The end result is really simple, normal Go code. It really doesn't take much to make this pattern work. Here's an example resolver:

package internal

import // ...

type Resolver struct {
    config Config
    
    logger *logging.ZapLogger
}

func NewResolver(config Config) *Resolver {
    return &Resolver{
        config: config,
    }
}

func (r *Resolver) ResolveLogger() *logging.ZapLogger {
    if r.logger == nil {
        r.logger = logging.NewZaplogger(config.Logging)
    }

    return r.logger
}

func (r *Resolver) ResolveFooClient() *foo.HTTPClient {
    return foo.NewHTTPClient(
        r.ResolveLogger(),
        r.config.HTTP,
    )
}

You can see how this can be fleshed out quite quickly. The only thing that the Resolver should need is the configuration. From there, it should be able to resolve all of your application dependencies as you ask for them. One really nice aspect of this is that you will end up with a really straightforward main.go. From there, your dependency graph is all stored in your resolver as normal Go code.

  • There's no magic going on. Main is kept small. Constructors are used in one place.
  • Dependencies are only created when they're asked for.
  • Dependencies can be shared between things by making a field on the resolver to store a dependency in (like the logger in the above example).
  • If a constructor changes, your code will no longer compile, telling you what you need to update. If a new dependency is introduced you'll know exactly where, and what to update - and it might just be one place.
  • By returning concretions it prevents the resolver from being passed around, and stops you from using it as a service locator. The rule of returning concretions and accepting interfaces works well for this pattern.

That covers everything I originally set out to in a pretty simple way. Another nice thing about this type is that you could technically have multiple resolver types in separate modules in your application, and then sort of like the config in the internal package, you could pull them all together in a root resolver. This might be a better approach for much larger applications.

cmd/hodor/main.go

Finally, to pull it all together, you can just start using the types as you see fit in your main function:

package main

import "github.com/seeruk/hodor/internal"

func main() {
    c := internal.LoadConfiguration()
    r := internal.NewResolver(c)
    
    fooClient := r.ResolveFooClient()
    fooClient.UpdateBarWithBaz()
}

In a more realistic application, this might mean creating an HTTP server by resolving a server. Or maybe it's a CLI application and it your use the resolver to get the root command to run, etc.

Comments

More posts in Go

The Gopher, There and Back Again
Prev post