Принципы SOLID в Go
SOLID -- пять принципов объектно-ориентированного проектирования, сформулированных Robert C. Martin. В Go они интерпретируются иначе, чем в классических ООП-языках, потому что Go не имеет классов и наследования. Тем не менее, эти принципы прекрасно ложатся на систему интерфейсов и пакетов Go.
S -- Single Responsibility Principle
Каждый модуль должен иметь одну и только одну причину для изменения.
В Go SRP применяется на уровне пакетов и типов: каждый пакет отвечает за одну область, каждый тип -- за одну задачу.
Плохо: God Struct
// Bad: UserManager does EVERYTHING related to users
type UserManager struct {
db *sql.DB
cache *redis.Client
mailer *smtp.Client
}
func (m *UserManager) Create(ctx context.Context, u *User) error {
// Validates user
// Saves to database
// Invalidates cache
// Sends welcome email
// Logs the action
// Updates metrics
return nil
}
func (m *UserManager) SendPasswordReset(ctx context.Context, email string) error { ... }
func (m *UserManager) GenerateReport(ctx context.Context) ([]byte, error) { ... }
func (m *UserManager) ExportToCSV(ctx context.Context) ([]byte, error) { ... }
Причины для изменения: БД-схема, формат email, кэширование, формат отчёта -- слишком много.
Хорошо: разделение ответственности
// UserRepository handles persistence only
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) Create(ctx context.Context, u *User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
u.ID, u.Name, u.Email,
)
return err
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) {
// ...
}
// UserService orchestrates business logic
type UserService struct {
repo UserStore
notifier Notifier
cache Cache
}
func (s *UserService) Register(ctx context.Context, u *User) error {
if err := u.Validate(); err != nil {
return fmt.Errorf("validating user: %w", err)
}
if err := s.repo.Create(ctx, u); err != nil {
return fmt.Errorf("creating user: %w", err)
}
s.cache.Invalidate(ctx, "users")
if err := s.notifier.SendWelcome(ctx, u.Email); err != nil {
// Non-critical: log but don't fail
slog.Error("sending welcome email", "err", err, "user", u.ID)
}
return nil
}
// Notifier handles email/push notifications
type Notifier struct {
mailer *smtp.Client
}
func (n *Notifier) SendWelcome(ctx context.Context, email string) error { ... }
В стандартной библиотеке
net/http-- HTTP-протоколencoding/json-- JSON-сериализацияdatabase/sql-- абстракция БДlog/slog-- структурированное логирование
Каждый пакет -- одна чётко определённая область.
O -- Open/Closed Principle
Код должен быть открыт для расширения, но закрыт для модификации.
В Go это достигается через интерфейсы: новое поведение добавляется через новые реализации, без изменения существующего кода.
Плохо: switch на конкретные типы
// Bad: adding a new notification type requires modifying this function
func SendNotification(notifType string, message string) error {
switch notifType {
case "email":
return sendEmail(message)
case "sms":
return sendSMS(message)
case "push":
return sendPush(message)
// Adding Telegram? Must modify this function!
default:
return fmt.Errorf("unknown notification type: %s", notifType)
}
}
Хорошо: интерфейс с подключаемыми реализациями
// Notifier is open for extension via new implementations
type Notifier interface {
Notify(ctx context.Context, msg Message) error
}
// EmailNotifier sends email notifications
type EmailNotifier struct {
client *smtp.Client
}
func (n *EmailNotifier) Notify(ctx context.Context, msg Message) error {
return n.client.Send(msg.To, msg.Subject, msg.Body)
}
// SlackNotifier sends Slack notifications
type SlackNotifier struct {
webhookURL string
}
func (n *SlackNotifier) Notify(ctx context.Context, msg Message) error {
// POST to Slack webhook
return nil
}
// NotificationService works with any Notifier -- closed for modification
type NotificationService struct {
notifiers []Notifier
}
func (s *NotificationService) NotifyAll(ctx context.Context, msg Message) error {
var errs []error
for _, n := range s.notifiers {
if err := n.Notify(ctx, msg); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Adding Telegram? Just create a new type -- no existing code changes
type TelegramNotifier struct { botToken string }
func (n *TelegramNotifier) Notify(ctx context.Context, msg Message) error { ... }
В стандартной библиотеке
io.Reader/io.Writer-- любой тип может реализоватьsort.Interface-- сортировка без изменения пакетаsorthttp.Handler-- middleware добавляется без измененияnet/httpencoding.TextMarshaler-- кастомная сериализация без измененияencoding/json
L -- Liskov Substitution Principle
Если S является подтипом T, то объекты типа T могут быть заменены объектами типа S без нарушения корректности программы.
В Go нет наследования, но LSP применяется к интерфейсам: любая реализация интерфейса должна полностью выполнять его контракт.
Плохо: реализация нарушает контракт
// ReadCloser requires both Read and Close to work
type ReadCloser interface {
Read(p []byte) (n int, err error)
Close() error
}
// Bad: NopCloser that doesn't actually close
type BrokenReadCloser struct {
data []byte
}
func (b *BrokenReadCloser) Read(p []byte) (int, error) {
return copy(p, b.data), io.EOF
}
func (b *BrokenReadCloser) Close() error {
panic("not implemented") // Violates LSP!
}
Хорошо: маленькие интерфейсы, полная реализация
// Small interfaces make LSP natural
type Reader interface {
Read(p []byte) (n int, err error)
}
// strings.Reader fully implements Reader contract
var _ Reader = (*strings.Reader)(nil) // compile-time check
// bytes.Buffer fully implements Reader contract
var _ Reader = (*bytes.Buffer)(nil) // compile-time check
Ключевой момент: в Go неявная реализация интерфейсов естественно поддерживает LSP. Если тип объявляет методы интерфейса, он обязан правильно их реализовать.
Проверка на этапе компиляции
// Compile-time assertion: ensure *MyType implements Interface
var _ Interface = (*MyType)(nil)
// This is a Go idiom to catch LSP violations early
var _ io.ReadWriteCloser = (*MyConn)(nil)
В стандартной библиотеке
io.NopCloser из stdlib -- правильный пример: Close() возвращает nil (no-op), что корректно для io.Closer.
I -- Interface Segregation Principle
Клиенты не должны зависеть от интерфейсов, которые они не используют.
Это самый естественный принцип для Go! Маленькие интерфейсы -- визитная карточка языка.
Плохо: толстый интерфейс
// Bad: forces implementers to define methods they don't need
type DataStore interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte) error
Delete(ctx context.Context, key string) error
List(ctx context.Context, prefix string) ([]string, error)
Watch(ctx context.Context, key string) (<-chan Event, error)
Transaction(ctx context.Context, fn func(Tx) error) error
Backup(ctx context.Context, w io.Writer) error
Restore(ctx context.Context, r io.Reader) error
}
// ReadOnlyCache only needs Get, but must implement everything
type ReadOnlyCache struct{}
func (c *ReadOnlyCache) Set(ctx context.Context, key string, value []byte) error {
return fmt.Errorf("not supported") // Forced to implement unused methods!
}
// ... all other methods also not supported
Хорошо: маленькие составные интерфейсы
// Small, focused interfaces
type Reader interface {
Get(ctx context.Context, key string) ([]byte, error)
}
type Writer interface {
Set(ctx context.Context, key string, value []byte) error
Delete(ctx context.Context, key string) error
}
type Lister interface {
List(ctx context.Context, prefix string) ([]string, error)
}
type Watcher interface {
Watch(ctx context.Context, key string) (<-chan Event, error)
}
// Compose when needed
type ReadWriter interface {
Reader
Writer
}
type Store interface {
Reader
Writer
Lister
}
// Functions accept only what they need
func loadConfig(ctx context.Context, r Reader) (*Config, error) {
data, err := r.Get(ctx, "config")
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
// ...
}
// ReadOnlyCache implements only Reader -- clean!
type ReadOnlyCache struct {
data map[string][]byte
}
func (c *ReadOnlyCache) Get(ctx context.Context, key string) ([]byte, error) {
v, ok := c.data[key]
if !ok {
return nil, ErrNotFound
}
return v, nil
}
Сравнение с Java
| Go | Java |
|---|---|
io.Reader (1 метод) |
java.io.InputStream (14+ методов) |
io.Writer (1 метод) |
java.io.OutputStream (7+ методов) |
fmt.Stringer (1 метод) |
Object.toString() + 10 других методов |
http.Handler (1 метод) |
javax.servlet.Servlet (5 методов) |
В стандартной библиотеке
// io package: perfect ISP examples
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }
type Seeker interface { Seek(offset int64, whence int) (int64, error) }
// Composed only when needed
type ReadWriter interface { Reader; Writer }
type ReadCloser interface { Reader; Closer }
type ReadWriteCloser interface { Reader; Writer; Closer }
type ReadWriteSeeker interface { Reader; Writer; Seeker }
D -- Dependency Inversion Principle
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
В Go: функции и типы принимают интерфейсы, а не конкретные реализации.
Плохо: зависимость от конкретной реализации
// Bad: OrderService directly depends on PostgresDB and SMTPMailer
type OrderService struct {
db *PostgresDB // concrete type -- can't test without Postgres!
mailer *SMTPMailer // concrete type -- can't test without SMTP server!
}
func NewOrderService() *OrderService {
db := NewPostgresDB("localhost:5432") // hardcoded!
mailer := NewSMTPMailer("smtp.gmail.com") // hardcoded!
return &OrderService{db: db, mailer: mailer}
}
func (s *OrderService) PlaceOrder(ctx context.Context, o *Order) error {
s.db.Save(ctx, o) // tied to Postgres
s.mailer.Send(o.CustomerEmail) // tied to SMTP
return nil
}
Хорошо: зависимость от интерфейсов
// Interfaces defined by the consumer (in service package, not repo package!)
type OrderStore interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
}
type OrderNotifier interface {
NotifyOrderPlaced(ctx context.Context, order *Order) error
}
// OrderService depends on abstractions
type OrderService struct {
store OrderStore
notifier OrderNotifier
logger *slog.Logger
}
// Constructor injection: dependencies provided externally
func NewOrderService(store OrderStore, notifier OrderNotifier, logger *slog.Logger) *OrderService {
return &OrderService{
store: store,
notifier: notifier,
logger: logger,
}
}
func (s *OrderService) PlaceOrder(ctx context.Context, o *Order) error {
if err := o.Validate(); err != nil {
return fmt.Errorf("validating order: %w", err)
}
if err := s.store.Save(ctx, o); err != nil {
return fmt.Errorf("saving order: %w", err)
}
// Non-critical notification
if err := s.notifier.NotifyOrderPlaced(ctx, o); err != nil {
s.logger.Error("notifying order placed", "err", err, "orderID", o.ID)
}
return nil
}
Реализации
// PostgreSQL implementation
type PostgresOrderStore struct {
db *pgxpool.Pool
}
func (s *PostgresOrderStore) Save(ctx context.Context, order *Order) error {
_, err := s.db.Exec(ctx,
"INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)",
order.ID, order.CustomerID, order.Total,
)
return err
}
// In-memory implementation for tests
type InMemoryOrderStore struct {
mu sync.RWMutex
orders map[string]*Order
}
func (s *InMemoryOrderStore) Save(_ context.Context, order *Order) error {
s.mu.Lock()
defer s.mu.Unlock()
s.orders[order.ID] = order
return nil
}
Тестирование
func TestOrderService_PlaceOrder(t *testing.T) {
store := &InMemoryOrderStore{orders: make(map[string]*Order)}
notifier := &MockNotifier{}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := NewOrderService(store, notifier, logger)
order := &Order{
ID: "order-1",
CustomerID: "cust-1",
Total: 99.99,
}
err := svc.PlaceOrder(context.Background(), order)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify order was saved
stored, err := store.FindByID(context.Background(), "order-1")
if err != nil {
t.Fatalf("finding order: %v", err)
}
if stored.Total != 99.99 {
t.Errorf("got total %v, want 99.99", stored.Total)
}
}
DI-фреймворки для Go
Для крупных проектов с множеством зависимостей:
| Фреймворк | Тип | Описание |
|---|---|---|
| wire (Google) | Compile-time | Генерирует код инъекции на этапе компиляции |
| fx (Uber) | Runtime | Рефлексия, автоматическое связывание |
| dig (Uber) | Runtime | Низкоуровневый DI-контейнер (fx построен поверх) |
| Ручной DI | -- | main() создаёт зависимости и передаёт в конструкторы |
Для большинства проектов ручной DI в main() -- лучший выбор: просто, явно, без магии.
func main() {
// Manual DI in main -- clear dependency graph
db := connectDB()
defer db.Close()
userRepo := postgres.NewUserRepo(db)
orderRepo := postgres.NewOrderRepo(db)
mailer := smtp.NewMailer(smtpConfig)
userSvc := service.NewUserService(userRepo)
orderSvc := service.NewOrderService(orderRepo, mailer, slog.Default())
handler := api.NewHandler(userSvc, orderSvc)
srv := &http.Server{
Addr: ":8080",
Handler: handler,
}
srv.ListenAndServe()
}