Знаю, что тема уже изъезжана вдоль и поперек, но я хотел бы поделиться своим видением Open/Close Principle из всеми любимым SOLID подходу к построении архитектуры софта. Ведь дядюшка Боб херни не посоветует, все таки опыта ему не занимать, поскольку он с 70х годов в разработке и знает базу, что нам и нужно. Да, современный софт ушел далеко от того какой он был в 70-х, когда писали логические цепочек на перфокартах, делая дырки в картоне и компиляция занимала прямо пропорционально количеству этих самых карточек, где скорость выполнения считалась количеством символов в минуту. За все это время Дядюшка Боб собирал лучшие практики из которых и получились эти 5 принципов, которые помогут построить софт, который будет не так сильно с течением времени влиять на стоимость одной строки кода. (О чем он и пишет в своей книги «Чистая архитектура»).
Хочу отметить то, что есть мнение, что принципы SOLID — это про ООП и для языков, которые не следуют этой парадигме это не актуально, нет. Эти принципы построения архитектуры приложения не зависят от языка.
Если вы читали книгу «Чистая архитектуры» и дошли до Open/Close principle (SOLID) и из примера ничего не поняли, тогда вы пришли по адресу, поскольку я буду рассматривать именно этот пример. Для меня лично OCP это один из принципов, который заставляет продумывать архитектуру приложения, что очень важно.
Я не буду писать тут тесты или использовать TDD подход к написанию кода, потому, что это отдельная тема, я сделаю простой http сервер с одним эндпойнтом для получения финансового отчета в разных форматах.
Невнятное ТЗ — результат ХЗ?
Самое сложное это получить подробное детальное ТЗ, это касается разработчиков любого уровня. Зачем? Для того, что бы мы смогли выделить бизнес логику, которую нужно закрыть от изменений и дать возможность только расширять функционал.
Если вы джун, миддл и получили тикет в джире, где все написано, надо прочитать и вникнуть, сможете ли вы из этого ТЗ выделить нужные для вас домены, бизнес логику, респондентов? Если нет, то вам надо сформировать ряд вопросов и пойти к лиду и задать их, а еще лучше созвониться с ним и попросить в деталях рассказать, что бизнес хочет (часто бывает так, что он мог что‑то упустить).
Если вы сеньор то вам тем более надо это выудить от бизнеса, что бы расписать подробно что именно и как надо сделать. без доп вопросов не получится.
Если вы лид — ну вы в курсе да?
Итак вот мы получили внятное ТЗ, которое у нас выглядит как:
Необходимо реализовать отчет по транзакциям за определенный период. Нужно отобразить общую сумму. Для каждой транзакции нужно вывести id, date, amount, description. Отчет может быть запрошен в разных форматах (пока что для web клиента в формате html) форматы пока на уточнении.
Не буду упарываться с тем, что если есть какая то верстка, то надо еще и дизайнера подключить, опустим этот момент для нашего примера
В реальном проекте вы уже имеет работающее приложение и вам надо будет продумать как встроить данную фичу в нее, но я для примера буду делать с чистого листа.
Сделаем структуру проекта, которая будет помимо OCP, отвечать еще и чистой архитектуре и даже некоторым остальным принципам (сюрприз, при реализации принципов SOLID вам придется реализовывать их все, потому, что эти принципы тянут друг друга так, что их все придется реализовать)
├── main.go # Точка входа, сборка зависимостей (Composition Root)
|
├── domain/ # Сущности. Ядро. Ни от чего не зависит.
│ └── report.go
|
├── usecase/ # Сценарии использования (Interactors). Зависят только от domain.
│ ├── interfaces.go # Абстракции (интерфейсы) для внешних слоев.
│ └── report_generator.go
|
└── interfaces/ # Внешний слой: адаптеры к фреймворкам, БД, UI.
├── controllers/ # Обработчики HTTP-запросов.
│ └── report_controller.go
├── gateways/ # Реализации шлюзов к данным.
│ └── in_memory_gateway.go
└── presenters/ # Презентеры и View-модели.
└── report_presenter.go
Тут для наглядности можно посмотреть на то как выглядит чистая архитектуры в разрезе

Начать надо с наших объектов, это транзакции и отчет, сделаем домены (модели) для них в /domain/report.go
package domain
import "time"
// Transaction - это базовая бизнес-сущность.
type Transaction struct {
ID uint `json:"id"`
Date time.Time `json:"date"`
Amount float64 `json:"amount"`
Description string `json:"description"`
}
// ReportData - это структура, с которой работает Interactor.
// Она не содержит информации о форматировании.
type ReportData struct {
Transactions []Transaction `json:"transactions"`
Total float64 `json:"total"`
}
Тут все понятно, есть модель транзакции, есть модель для репорта, которые не зависят ни от чего.
DAL — Data Access Layer
Очень частый подход для реализации доступа к данным. Данные могу хранится как в СУБД, так и в файле а могут быть получены по сети, не важно, данный слой мы будет реализовывать с оглядкой на интерфейс, которым будет пользоваться наш usecase
. Итак прежде чем реализовать DAL, давайте накидаем интерфес и положем его там где мы будем его использовать (не реализовывать — это важно) создадим файл для интерфесов /usecase/interfaces.go
package usecase
import (
"context"
"solid-go/domain"
"time"
)
// FinancialDataGateway - это порт (абстракция) для получения данных.
// Interactor зависит от этого интерфейса, а не от конкретной БД.
type FinancialDataGateway interface {
GetTransactions(ctx context.Context, start, end time.Time) ([]domain.Transaction, error)
}
// ReportPresenter - это порт для вывода данных.
// Он получает чистые бизнес-данные и отвечает за их подготовку к отображению.
type ReportPresenter interface {
Present(ctx context.Context, data domain.ReportData) ([]byte, error)
}
Тут содержится еще один интерфейс, это интерфейс нашей будущей реализации презентера — модуль, который будет формировать именно презентацию нашего отчета (html/json/xml/bytes/text). А теперь давайте сделаем реализацию нашего гейтвея для данных, положим его в /interfaces/gateway/in_memory_gateway.go
package gateway
import (
"context"
"solid-go/domain"
"sync"
"time"
)
type InMemoryReportGateway struct {
mu *sync.Mutex
nextId uint
transactions map[uint]domain.Transaction
}
func NewInMemoryReportGateway() *InMemoryReportGateway {
return &InMemoryReportGateway{
mu: &sync.Mutex{},
nextId: 0,
transactions: make(map[uint]domain.Transaction),
}
}
// func (rg *InMemoryReportGateway) getNextIdLock() uint {
// rg.mu.Lock()
// defer rg.mu.Unlock()
// return rg.getNextId()
// }
func (rg *InMemoryReportGateway) getNextId() uint {
rg.nextId++
return rg.nextId
}
func (rg *InMemoryReportGateway) Init() {
rg.mu.Lock()
defer rg.mu.Unlock()
id := rg.getNextId()
rg.transactions[id] = domain.Transaction{
Date: time.Now(),
Amount: 150.50,
Description: "Buying the book",
ID: id,
}
id = rg.getNextId()
rg.transactions[id] = domain.Transaction{
Date: time.Now(),
Amount: -30.00,
Description: "Refund",
ID: id,
}
id = rg.getNextId()
rg.transactions[id] = domain.Transaction{
Date: time.Now(),
Amount: 1200.00,
Description: "Salary",
ID: id,
}
}
func (rg *InMemoryReportGateway) GetTransactions(_ context.Context, start, end time.Time) ([]domain.Transaction, error) {
transactions := make([]domain.Transaction, 0, len(rg.transactions))
for _, tx := range rg.transactions {
transactions = append(transactions, tx)
}
return transactions, nil
}
Очень условный пример, понятно, что можно было обойтись без Мьютекса, и просто вернуть тут слайс, но я сделал так, потому, что я все таки буду использовать это далее, в этом учебном проекте, и тут будет и добавление и удаление и поиск. Еще я тут не ищу по датам, даты тут просто для примера.
Теперь, когда у нас есть интерфейсы давайте реализуем наш usecase
, который и будет у нас Close (Не изменяемой частью бизнес логики). /usecase/report_generator.go
package usecase
import (
"context"
"solid-go/domain"
"time"
)
// ReportGenerator - это наш Interactor (Use Case).
type ReportGenerator struct {
gateway FinancialDataGateway
presenter ReportPresenter
}
// NewReportGenerator - конструктор для Interactor'а.
func NewReportGenerator(gw FinancialDataGateway, p ReportPresenter) *ReportGenerator {
return &ReportGenerator{
gateway: gw,
presenter: p,
}
}
// Generate - выполняет основной сценарий использования.
func (rg *ReportGenerator) Generate(ctx context.Context, start, end time.Time) ([]byte, error) {
// 1. Получить данные через абстрактный шлюз
transactions, err := rg.gateway.GetTransactions(ctx, start, end)
if err != nil {
return nil, err
}
// 2. Выполнить бизнес-логику (здесь - простое суммирование)
var total float64
for _, t := range transactions {
total += t.Amount
}
reportData := domain.ReportData{
Transactions: transactions,
Total: total,
}
// 3. Передать данные в абстрактный презентер для форматирования
return rg.presenter.Present(ctx, reportData)
}
Именно тут заложен главный принцип Открытости/Закрытости. Мы закрываем нашу бизнес логику от изменений, но открываем возможность для расширения. Если нам надо будет добавить новый формат отображения, мы просто создадим новый модуль, который будет удовлетворять интерфейс ReportPresenter
и все. Итак давайте же напишем модули, которые будут отвечать ему /interfaces/presenters/report_presenter.go
package presenters
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"solid-go/domain"
)
// ViewModel - структура, оптимизированная для отображения.
// Презентер преобразует domain.ReportData в ViewModel.
type reportViewModel struct {
GeneratedDate string
Transactions []transactionViewModel
FinalTotal string
}
type transactionViewModel struct {
Date string
Description string
Amount string
}
// --- HTML Presenter ---
type HtmlReportPresenter struct{}
func NewHtmlReportPresenter() *HtmlReportPresenter {
return &HtmlReportPresenter{}
}
func (p *HtmlReportPresenter) Present(_ context.Context, data domain.ReportData) ([]byte, error) {
viewModel := p.toViewModel(data)
// Здесь мы бы использовали html/template для генерации красивого отчета.
// Для простоты, сгенерируем простую HTML-строку.
tpl := `
<!DOCTYPE html>
<html>
<head><title>Finance report</title></head>
<body>
<h1>Report from {{.GeneratedDate}}</h1>
<table border="1">
<tr><th>Date</th><th>Description</th><th>Sum</th></tr>
{{range .Transactions}}
<tr><td>{{.Date}}</td><td>{{.Description}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
<p>Total: <strong>{{.FinalTotal}}</strong></p>
</body>
</html>`
tmpl, err := template.New("report").Parse(tpl)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, viewModel); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// --- JSON Presenter ---
type JsonReportPresenter struct{}
func NewJsonReportPresenter() *JsonReportPresenter {
return &JsonReportPresenter{}
}
func (p *JsonReportPresenter) Present(_ context.Context, data domain.ReportData) ([]byte, error) {
viewModel := p.toViewModel(data)
return json.MarshalIndent(viewModel, "", " ")
}
// Общая логика для обоих презентеров
func (p *HtmlReportPresenter) toViewModel(data domain.ReportData) reportViewModel {
return commonToViewModel(data)
}
func (p *JsonReportPresenter) toViewModel(data domain.ReportData) reportViewModel {
return commonToViewModel(data)
}
func commonToViewModel(data domain.ReportData) reportViewModel {
txViewModels := make([]transactionViewModel, len(data.Transactions))
for i, tx := range data.Transactions {
txViewModels[i] = transactionViewModel{
Date: tx.Date.Format("02-01-2006"),
Description: tx.Description,
Amount: fmt.Sprintf("%.2f", tx.Amount),
}
}
return reportViewModel{
GeneratedDate: "Today",
Transactions: txViewModels,
FinalTotal: fmt.Sprintf("%.2f RUB", data.Total),
}
}
Тут у нас любой презентер возвращает слайс байтов, что можно сконвертировать в конроллере в нужный формат. Добавим наш контроллер /interfaces/controllers/report_controller.go
package controllers
import (
"context"
"net/http"
"solid-go/interfaces/presenters"
"solid-go/usecase"
"time"
)
type ReportController struct {
// Зависимости контроллера - это фабрики или конкретные реализации
// из внешних слоев. В данном случае - шлюз.
gateway usecase.FinancialDataGateway
}
func NewReportController(gateway usecase.FinancialDataGateway) *ReportController {
return &ReportController{gateway: gateway}
}
func (c *ReportController) GenerateReportHandler(w http.ResponseWriter, r *http.Request) {
// 1. Выбираем нужный Presenter в зависимости от запроса.
// Это ключевой момент для OCP!
var presenter usecase.ReportPresenter
format := r.URL.Query().Get("format")
contentType := ""
switch format {
case "json":
presenter = presenters.NewJsonReportPresenter()
contentType = "application/json"
case "html":
fallthrough // html будет по умолчанию
default:
presenter = presenters.NewHtmlReportPresenter()
contentType = "text/html"
}
// 2. Создаем Interactor, "внедряя" в него нужные зависимости.
reportGenerator := usecase.NewReportGenerator(c.gateway, presenter)
// 3. Запускаем Use Case.
report, err := reportGenerator.Generate(context.Background(), time.Now(), time.Now())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 4. Отдаем результат.
w.Header().Set("Content-Type", contentType)
w.Write(report)
}
Ну и вишенка на торте это наша точка старта приложения main.go
package main
import (
"log"
"net/http"
"solid-go/interfaces/controllers"
"solid-go/interfaces/gateway"
)
func main() {
reportBbGateway := gateway.NewInMemoryReportGateway()
reportBbGateway.Init()
reportController := controllers.NewReportController(reportBbGateway)
// --- Настройка веб-сервера ---
http.HandleFunc("/report", reportController.GenerateReportHandler)
log.Println("Сервер запущен на http://localhost:8080")
log.Println("Примеры запросов:")
log.Println("http://localhost:8080/report?format=html")
log.Println("http://localhost:8080/report?format=json")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Не удалось запустить сервер: %v", err)
}
}
Надеюсь эта статья найдет своего читателя и поможет разобраться с SOLID и архитектурой.