This post contains some personal experiences formed while working on some personal projects. Do not use any of these in your work or your teams ¯\_(ツ)_/¯
.
Use this directory layout:
.
├── core
│ ├── actions
│ │ ├── roll-dice.go
│ │ └── roll-event.go
│ └── model
│ ├── dice.go
│ └── event-oracle.go
└── delivery
├── bot
│ └── main.go
├── grpc
│ └── main.go
└── web
└── main.go
This is a simple program. It performs two actions: rolling dice and choosing a random event. Even after some years, when you return to this project, you will immediately know what it does. This specific project is written in Go (golang). I have reimplemented the same thing in TypeScript. And recently, I have reimplemented it partially in Rust. In TypeScript and Rust case, this directory layout sits under the src directory. The program provides much more functionality. But the size of the codebase is not important. What is important is the first level of communication in a codebase is the directory layout. The codebase is not a proper pile of crap code if it is absent. It is a crappy pile of crap code.
If our program is a bit bigger and has multiple entities, we group things under directories named after those entities:
.
├── core
│ ├── actions
│ │ ├── character
│ │ │ ├── create-npc.go
│ │ │ ├── generate-motive.go
│ │ │ └── generate-name.go
│ │ └── oracle-table
│ │ ├── roll-event.go
│ │ └── roll-location.go
│ └── model
│ ├── character
│ │ ├── character.go
│ │ └── service.go
│ ├── dice.go
│ └── oracle-table
│ └── service.go
└── delivery
├── bot
│ └── main.go
├── grpc
│ └── main.go
└── web
└── main.go
You get the idea. This directory layout makes it easier to onboard to an old project and pick up everything quickly. Directory Layout is the first step/level of communication, not the README.
- The code for
model
can not depend on other parts. - The code for
actions
can depend only onmodel
. - The code for
delivery
can depend on both the code foractions
and the code formodel
.
An action is an interface with this signature:
type Action[CTX context.Context, IN, OUT any, ERROR error] interface {
Do(ctx CTX, in IN) (result OUT, err ERROR)
}
It receives an execution context and the input. It returns an output or an error. In the Go version, the context is of type context.Context. In the TypeScript version, it is just a number representing the timeout. In the TypeScript version, instead of returning an error, it throws an exception.
Stuff before the action does not leak into stuff below the action.
- depend on abstractions but inject the real thing while testing.
- do not run tests in docker containers.
- if a dependency (like a database) is fast to respond, run it locally in a container, have it up during development and run your tests against it. as long as it is a dependency which is fast to respond and you can have it locally, do it.
- if a dependency is very slow to respond, or it is not possible to have it locally, mock it in the tests or use a fake. there are some tools that help creating fake versions of a HTTP API - for example.
- write unit tests first when you understand what you want to do, but you want to discover the best (meh) way to do it.
- if you don't understand the problem and still want to write code ... be careful and patient; rinse and repeat.
- log-based/signal-based testing/verification.