Go Echo Framework + DDD + CQRS: Part 1.1 [pkg]
This is a minor part of a series of articles in which I want to separately discuss the purpose of the pkg
directory. In this article, we will look at how to organize the code in the pkg
directory to configure the logger using the zap
and viper
libraries. We will create the package structure and implement an abstract factory to create different types of logger
Why a minor part?
This article complements the first one and helps offload the second part, as it is becoming quite extensive. Additionally, in this intermediate article, we will address the technical debt left after the first part.
A bit of theory
The pkg
directory is not directly related to DDD and CQRS, but it plays an important role. This directory is intended for storing common code and libraries that can be reused in various parts of your project or even in other projects. By structuring the code this way, you can improve its modularity and reusability, which contributes to a cleaner and more maintainable architecture.
Example of implementing zap.Logger configured via Viper
In this section, we will look at how to create a customizable logger using the powerful capabilities of the zap library for logging and the viper library for configuration. The goal is to allow logger configuration through a configuration file or other configuration sources supported by viper.
Let’s add a configuration for our zap.Logger
in config.dev.yaml
:
zap:
cores:
console:
type: stream
level: info
encoding: console
time_encoder: iso8601
file:
type: "file"
encoding: json
time_encoder: iso8601
file:
path: "/var/log/myapp"
max_backups: 7
max_age: 30
max_size: 32
This configuration file sets up two core
components for zap.Logger
: console and file. The console core uses console-style formatting, while the file core saves logs in JSON format. The parameters time_encoder
, level
, max_backups
, max_age
, and max_size
allow detailed customization of how and where the logs will be stored.
A core in zap is a central component that handles log writing. It receives messages from the logger, formats them, and sends them to the appropriate output channels (e.g., console, file, remote server, etc.). Each core can be configured for different logging levels and various output formats.
For more details, you can refer to the documentation on the package page: zapcore on pkg.go.dev.
Directory structure of pkg
In the pkg directory, we will now create the structure and files for our package.
pkg/
└── zap/
├── core/
│ ├── core.go # Abstract factory for creating `zapcore.Core`
│ ├── file.go # Implementation for file logging
│ └── stream.go # Implementation for stream output logging
└── logger.go # Logger constructor
core/core.go
: Here, an abstract factory Core is defined, which creates the corresponding instance of zapcore.Core based on the configuration — either for file logging or stream output logging. This makes it easy to extend logging capabilities by adding new output types.
package core
import (
"fmt"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type Core interface {
Create(cfg *viper.Viper) (zapcore.Core, error)
}
func Create(cfg *viper.Viper, path string) (zapcore.Core, error) {
cfgCore := cfg.Sub(path)
if cfgCore == nil {
return nil, fmt.Errorf("core config at path '%s' not found", path)
}
cfgCore.SetDefault("type", TypeCoreStream)
cfgCore.SetDefault("level", "info")
var core Core
coreType := cfgCore.GetString("type")
switch coreType {
case TypeCoreStream:
core = &StreamCore{}
case TypeCoreFile:
core = &FileCore{}
default:
return nil, fmt.Errorf("unsupported core type: %s", coreType)
}
return core.Create(cfgCore)
}
core/stream.go
and core/file.go
: These files contain specific implementations of zapcore.Core for different logging methods. StreamCore is intended for logging to standard output or error streams, while FileCore configures logging to a file with rotation and size limit capabilities.
Example implementation for core/stream.go
package core
import (
"os"
"github.com/spf13/viper"
"go.uber.org/zap/zapcore"
)
type StreamCore struct{}
const TypeCoreStream = "stream"
func (c *StreamCore) Create(cfg *viper.Viper) (_ zapcore.Core, err error) {
var encoder zapcore.Encoder
if encoder, err = getEncoder(cfg); err != nil {
return nil, err
}
var level zapcore.LevelEnabler
if level, err = getLevel(cfg); err != nil {
return nil, err
}
return zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level), nil
}
logger.go
: This file contains the package constructor, which initializes and returns an instance of zap.Logger configured according to the configuration obtained through Viper. This is the entry point for creating a customizable logger.
package zap
import (
"github.com/i4erkasov/go-ddd-cqrs/pkg/zap/core"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func New(cfg *viper.Viper) (*zap.Logger, error) {
// Retrieve logger core configuration from Viper
cfgCore := cfg.Sub("zap.cores")
settings := cfgCore.AllSettings()
cores := make([]zapcore.Core, 0, len(settings))
for name := range settings {
c, err := core.Create(cfg, "zap.cores."+name)
if err != nil {
return nil, err
}
cores = append(cores, c)
}
// Add logger options
var opts []zap.Option
if cfg.IsSet("zap.development") && cfg.GetBool("zap.development") {
opts = append(opts, zap.Development())
}
if cfg.GetBool("zap.caller") {
opts = append(opts, zap.AddCaller())
}
if cfg.IsSet("zap.stacktrace") {
var level = zap.NewAtomicLevel()
if err := level.UnmarshalText([]byte(cfg.GetString("zap.stacktrace"))); err != nil {
return nil, err
}
opts = append(opts, zap.AddStacktrace(level))
}
return zap.New(zapcore.NewTee(cores...), opts...), nil
}
Technical Debt
In the previous article, we had temporary code for the logger in the http-server command to start our server:
bws := &zapcore.BufferedWriteSyncer{
WS: os.Stderr,
Size: 512 * 1024,
FlushInterval: time.Minute,
}
defer bws.Stop()
consoleEncoder := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig())
core := zapcore.NewCore(consoleEncoder, bws, zapcore.DebugLevel)
log := zap.New(core)
Now we can remove it and use our new implementation from the pkg package.
Here is how our corrected command will look:
package cli
import (
"github.com/i4erkasov/go-ddd-cqrs/internal/infrastructure/api/rest"
"github.com/i4erkasov/go-ddd-cqrs/pkg/zap"
"github.com/spf13/cobra"
zapLogger "go.uber.org/zap"
)
const HttpServerCommand = "http-server"
const VersionHttpServer = "1.0.0"
var httpServer = &cobra.Command{
Use: HttpServerCommand,
Short: "Start http server",
Version: VersionHttpServer,
RunE: func(cmd *cobra.Command, args []string) (err error) {
cfg = cfg.Sub("app")
var log *zapLogger.Logger
if log, err = zap.New(cfg); err != nil {
return err
}
var server *rest.Server
if server, err = rest.New(cfg.Sub("api.rest"), log); err != nil {
return err
}
return server.Start(cmd.Context())
},
}
func init() {
cmd.AddCommand(httpServer)
}
Conclusions and Next Steps
In this article, we have thoroughly examined how to use the pkg directory to create a customizable logger using the zap and viper libraries. We created the package structure, implemented an abstract factory to create different types of loggers, and addressed the technical debt left from the previous part. By discussing the organization of code in the pkg directory, we won’t need to delve into this topic in future articles, allowing us to focus on other important aspects of the Go Echo Framework, DDD, and CQRS.
The complete second part of the series is already on the way, where we will continue our deep dive into the Go Echo Framework, DDD, and CQRS.
You can find a more detailed implementation in the repository on GitHub:
https://github.com/i4erkasov/go-ddd-cqrs/tree/part-1.1