Знаю, что тема уже изъезжана вдоль и поперек, но я хотел бы поделиться своим видением 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 и архитектурой.

Комментарии (0)