Ces derniers temps, je me suis remis au Go pour pouvoir postuler à plus de missions et emplois vu que mon pari sur Erlang/Elixir s’est avéré infructueux (il n’y a pas assez d’entreprises qui utilisent ces technos). Je connaissais déjà la syntaxe car j’avais dû modifier des outils CLI et TUI lors de mes travaux sur Archethic. Par contre je ne connaissais rien au tooling, à la façon d’organiser les packages etc.
J’avoue que le fait que plusieurs fichiers partagent le même package reste une énigme pour moi. Je ne comprends vraiment pas ce choix de design.
Je commence tranquillement en écrivant des types avec des méthodes représentant le domaine de mon projet puis je m’attaque à l’API.
Je ne sais pas trop pourquoi, je décide de partir sur du gRPC, plus pour apprendre que par besoin, et j’attaque donc mes fichiers protos pour mes services. Ca ressemble à ça, c’est plutôt trivial :
service AuthService {
rpc Login(LoginRequest) returns (LoginResponse);
rpc ForgotPwd(ForgotPwdRequest) returns (ForgotPwdResponse);
rpc ResetPwd(ResetPwdRequest) returns (ResetPwdResponse);
}
message LoginRequest {
string username = 1;
string password = 2;
}
message LoginResponse {
string token = 1;
}
// ...
Le point fort de protobuf est de définir des services dans un langage dédié et de générer du code dans les langages cibles : go pour le backend et typescript pour le frontend par exemple. J’aime ce principe car on est sur de casser la compilation dès qu’un breaking change est fait au niveau des services (ce qui est souhaité).
Errors should be caught as early as possible — ideally at compile time
De plus, grâce au protobuf on a une serialization binaire ultra performante. Adieu le JSON (sauf en mode dev).
Là, je découvre — je ne sais plus comment — qu’il existe un outil qui s’appelle Buf.build qui est plutôt populaire et qui me permet d’automatiser certaines choses. Je l’ajoute donc à mon build (un Makefile). Puis, en faisant le tutoriel, j’ai ajouté le plugin connectrpc (ils sont malins). Ce plugin sert à générer du code pour générer les struct pour ajouter automatiquement les services au framework Connect. Evidemment, fait par la même team que Buf.build.
Je décide d’aller dans le sens du courant et d’utiliser ce framework. Grâce à lui, je n’ai pas besoin de faire un serveur GRPC et un serveur HTTP car il combine les deux, sur un même port. Ce qui me ravit car j’avais une énorme duplication de code entre les services GRPC et HTTP.
Je suis arrivé à faire du DDD sans le vouloir. Au début, j’ai séparé les domaines pour le côté pratique, puis dans chaque domaine je sépare la modélisation, la logique métier, les endpoints… Je prend donc la dernière décision structurante pour l’architecture de ce projet : implémenter en DDD du mieux que je peux.
A partir de là, j’ai fait moult itérations afin d’obtenir une structure de dossier qui me plaît. En go la structure de dossier fait énormément car c’est elle qui définit les “frontières” des packages.
Voilà le résultat :
internal/
├── account/
│ ├── application/ # services
│ ├── infrastructure/ # adapters
│ └── transport/connect/ # service handlers
├── auth/
│ ├── application/
│ ├── infrastructure/
│ └── transport/connect/
├── playlist/
│ ├── application/
│ ├── infrastructure/
│ └── transport/connect/
└── transport/connect/ # wires all the service handlers
Dossier internal/playlist
L’aggregate du domaine. Principalement de la déclaration. Tous les autres packages dépendront de celui-là et c’est ici que je défini des choses pour enforcer les règles métiers (ex: label > 4 et < 20 runes). C’est aussi ici qu’on déclarera les interfaces qui nous permettront de mocker certaines couches dans les tests unitaires.
Exemple :
var (
ErrPlaylistNotFound = fmt.Errorf("playlist not found")
ErrLabelLength = fmt.Errorf("label must be between %d and %d characters", LabelMinLength, LabelMaxLength)
ErrDescriptionLength = fmt.Errorf("description must be at most %d characters", DescriptionMaxLength)
ErrInvalidVideoURL = fmt.Errorf("invalid video URL")
ErrUnauthorized = fmt.Errorf("user is not authorized to modify this playlist")
)
type Repository interface {
Save(ctx context.Context, playlist *Playlist) error
FindByID(ctx context.Context, id PlaylistID) (*Playlist, error)
FindByOwner(ctx context.Context, owner string) ([]*Playlist, error)
Search(ctx context.Context, query string) ([]*Playlist, error)
SaveVideo(ctx context.Context, video *Video) error
FindVideosByPlaylistID(ctx context.Context, id PlaylistID) ([]*Video, error)
}
type Service interface {
CreatePlaylist(ctx context.Context, in CreatePlaylistInput) (CreatePlaylistOutput, error)
SearchPlaylists(ctx context.Context, in SearchPlaylistsInput) (SearchPlaylistsOutput, error)
OwnedPlaylists(ctx context.Context, in OwnedPlaylistsInput) (OwnedPlaylistsOutput, error)
GetPlaylist(ctx context.Context, in GetPlaylistInput) (GetPlaylistOutput, error)
AddVideo(ctx context.Context, in AddVideoInput) (AddVideoOutput, error)
ListVideos(ctx context.Context, in ListVideosInput) (ListVideosOutput, error)
}
type Label struct{ value string }
func (l Label) String() string { return l.value }
func NewLabel(raw string) (Label, error) {
if !alphabet.ValidateRuneCount(raw, LabelMinLength, LabelMaxLength) {
return Label{}, ErrLabelLength
}
return Label{value: raw}, nil
}
type Playlist struct {
id PlaylistID
label Label
description Description
owner UserID
moderators []UserID
createdAt time.Time
updatedAt time.Time
}
// Accessors (the aggregate keeps its fields private).
func (p *Playlist) ID() PlaylistID { return p.id }
func (p *Playlist) Label() Label { return p.label }
func (p *Playlist) Description() Description { return p.description }
func (p *Playlist) Owner() UserID { return p.owner }
func (p *Playlist) Moderators() []UserID { return p.moderators }
func (p *Playlist) CreatedAt() time.Time { return p.createdAt }
func (p *Playlist) UpdatedAt() time.Time { return p.updatedAt }
func NewPlaylist(label Label, description Description, owner UserID, moderators []UserID) *Playlist {
return &Playlist{
id: PlaylistID(uuid.New().String()),
label: label,
description: description,
owner: owner,
moderators: moderators,
createdAt: time.Now().UTC(),
updatedAt: time.Now().UTC(),
}
}
Rôles :
- défini les types (ex: PlaylistID, Playlist)
- défini les interfaces (ex: Repository, Service)
- défini les erreurs
Tests :
Ici les tests sont simples car le domaine ne dépend de rien. On peux donc tout tester unitairement sans aucun mocks.
Dossier internal/playlist/application
C’est le dossier qui contient les services. Les services contiennent la logique métier qui n’est pas directement liée à une entité ou un value object.
Exemple :
func (s *Service) CreatePlaylist(ctx context.Context, in playlist.CreatePlaylistInput) (playlist.CreatePlaylistOutput, error) {
label, err := playlist.NewLabel(in.Label)
if err != nil {
return playlist.CreatePlaylistOutput{}, fmt.Errorf("input err: %w", err)
}
description, err := playlist.NewDescription(in.Description)
if err != nil {
return playlist.CreatePlaylistOutput{}, fmt.Errorf("input err: %w", err)
}
pl := playlist.NewPlaylist(label, description, in.UserID, []playlist.UserID{})
if err := s.playlistRepo.Save(ctx, pl); err != nil {
return playlist.CreatePlaylistOutput{}, fmt.Errorf("saving playlist: %w", err)
}
return playlist.CreatePlaylistOutput{
ID: pl.ID(),
}, nil
}
Rôles :
- implémentation du service
- implémentation des rpcs (ex: AddVideo, CreatePlaylist)
Tests :
Ici les tests nécessitent du setup. Par exemple pour un service qui va lire des choses dans la base de données, on va devoir utiliser un mock pour celle-ci (MockPlaylistRegistry).
Le fait d’avoir déclaré une interface pour le registry dans le domaine permet de pouvoir mocker celui-ci.
Dossier internal/playlist/transport/connect
C’est le dossier qui contient les services handler. Ils utilisent le code généré pour enregistrer les services sur un transport (http.ServeMux). Le code est très simple et il s’agit uniquement de plomberie.
Exemple :
func (s *ServiceHandler) CreatePlaylist(ctx context.Context, req *connect.Request[playlistv1.CreatePlaylistRequest]) (*connect.Response[playlistv1.CreatePlaylistResponse], error) {
userID, ok := contextutils.UserIDFromContext(ctx)
if !ok {
return nil, s.mapError(playlist.ErrUnauthenticated)
}
out, err := s.svc.CreatePlaylist(ctx, playlist.CreatePlaylistInput{
Label: req.Msg.Label,
Description: req.Msg.Description,
UserID: playlist.UserID(userID),
})
if err != nil {
return nil, s.mapError(err)
}
return connect.NewResponse(&playlistv1.CreatePlaylistResponse{
Id: string(out.ID),
}), nil
}
Rôles :
- Pour chaque transport souhaité (http/grpc) :
- Extraire les données de la requête RPC
- Créer la struct qui va bien pour le service
- Appeler le service
- Créer la réponse RPC en fonction du résultat du service
Tests :
Ici on teste la transformation des messages et la gestion d’erreur. On utilise un mock pour le service.
Le fait d’avoir déclaré une interface pour le service dans le domaine permet de pouvoir mocker celui-ci.
Dossier internal/playlist/infrastructure
C’est le dossier qui contient les détails d’implémentation. L’implémentation des adapters. Les choses qui peuvent changer avec le temps. Par exemple le repository qui est déclaré dans domaine peut être implémenter ici avec Postgres, Mongo, Redis, etc. Le changement d’implémentation du repository doit être complétement opaque pour le reste du code.
Exemple :
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) FindByID(ctx context.Context, id playlist.PlaylistID) (*playlist.Playlist, error) {
const q = `
SELECT id, label, description, owner, moderators, created_at, updated_at
FROM playlists WHERE id = $1`
row := r.db.QueryRowContext(ctx, q, id)
return r.scan(row)
}
Rôles :
- Implemente les interfaces dites adapters
Tests :
Ici les tests nécessitent plus de mise en place. Par exemple pour tester un repository qui utilise une base SQL, il va falloir créer une base vierge et la seeder (remplir) pour les besoins de chaque test.
Dossier internal/transport/connect
Contient le serveur HTTP, les règles CORS, les interceptors (middleware de connect) et appelle le code généré pour enregistrer les routes.
C’est ce que j’avais dans mon main.go mais mon linter m’a dit que c’était mieux ainsi.
Conclusion
Je suis plutôt content du résultat, on peut vraiment tester à différent niveaux et chaque chose a sa place.
Dans un précédent travail où l’architecture était DDD-ish, on se retrouvait souvent à tester plusieurs fois la même chose et c’était compliqué à tester car les niveaux n’étaient pas correctement définis et on se retrouvait à mocker énormément de parties non-pertinente pour les tests.
Liste des outils que j’utilise
- buf.build # proto generation - connectrpc # web+grpc framework - golangci-lint # linter, formatter - staticcheck # linter - goda # deps graph