Тестовое задание: REST API организационной структуры компании (подразделения с самовложенным деревом + сотрудники).
| Слой | Технология |
|---|---|
| Язык | Go 1.22 |
| HTTP | net/http (стандартный ServeMux с path-параметрами) |
| ORM | GORM |
| БД | PostgreSQL 16 |
| Миграции | goose (SQL-миграции) |
| Логирование | log/slog (структурированный JSON) |
| Тесты | net/http/httptest + testify |
| Деплой | Docker + docker-compose |
git clone https://github.com/bukleeff-ux/orgapi
cd orgapi
docker-compose up --buildЧерез 5–10 секунд API будет доступен по адресу http://localhost:8080.
Postgres поднимется в том же compose; миграции накатываются автоматически
при старте контейнера API (см. scripts/entrypoint.sh).
Проверка живости:
curl http://localhost:8080/healthz
# → okЗапуск без Docker, локально:
go test ./...Тесты не требуют Postgres — HTTP-слой проверяется через httptest
с мок-сервисами, бизнес-логика покрыта табличными тестами на уровне сервиса.
.
├── cmd/api/ точка входа: main.go
├── internal/
│ ├── config/ загрузка ENV-переменных
│ ├── apperror/ типизированные доменные ошибки
│ ├── domain/ модели Department, Employee
│ ├── repository/ GORM-обёртки, без бизнес-логики
│ ├── service/ бизнес-логика: валидация, циклы, транзакции
│ ├── middleware/ HTTP-middleware (логгер запросов)
│ └── handler/ HTTP-хендлеры + роутер на ServeMux
├── migrations/ goose SQL-миграции
├── scripts/entrypoint.sh init контейнера: wait-for-pg → goose up → api
├── tests/ httptest + testify
├── Dockerfile multi-stage build
├── docker-compose.yml
└── README.md
Архитектура — классический трёхслойный backend: handler → service → repository. Каждый слой зависит только от слоя ниже, бизнес-логика изолирована от HTTP и от GORM. Это даёт лёгкое мокание (см. тесты) и независимость от выбора HTTP-фреймворка или ORM.
Все ответы — JSON. Ошибки приходят в формате {"error": "..."}.
POST /departments/
Content-Type: application/json
{
"name": "Backend",
"parent_id": 1
}
Тело:
name— строка 1..200, обязательна.parent_id— uint илиnull. Если задан — депортамент должен существовать.
Ответ 201 Created:
{ "id": 5, "name": "Backend", "parent_id": 1, "created_at": "..." }GET /departments/{id}?depth=2&include_employees=true
Query:
depth— 0..5 (по умолчанию 1). Глубина поддерева в ответе.include_employees—true|false|1|0(по умолчаниюtrue).
Ответ 200 OK:
{
"id": 1,
"name": "Company",
"parent_id": null,
"created_at": "...",
"employees": [ ... ],
"children": [
{
"id": 2,
"name": "R&D",
"employees": [ ... ],
"children": [ ... ]
}
]
}POST /departments/{id}/employees/
Content-Type: application/json
{
"full_name": "Иван Иванов",
"position": "Backend Engineer",
"hired_at": "2024-09-01"
}
hired_at — формат YYYY-MM-DD, опционален.
Ответ 201 Created с созданным сотрудником.
PATCH /departments/{id}
Content-Type: application/json
{
"name": "Backend Platform",
"parent_id": 7
}
Любое из полей опционально. Чтобы перенести в корень — "parent_id": null.
DELETE /departments/{id}?mode=cascade
DELETE /departments/{id}?mode=reassign&reassign_to_department_id=10
mode=cascade(по умолчанию) — каскадно удаляет депортамент, все его подразделения и всех сотрудников. Каскад реализован на уровне БД черезON DELETE CASCADEв foreign keys.mode=reassign— переводит сотрудников вreassign_to_department_id, затем удаляет депортамент. Допустимо только если у депортамента нет дочерних подразделений (иначе вернётся409 Conflict).
Ответ 204 No Content.
| Ситуация | Код |
|---|---|
| Депортамент / сотрудник / родитель не найден | 404 |
name пустое или длиннее 200 символов |
400 |
| Невалидный JSON, неверный формат даты | 400 |
mode=reassign без reassign_to_department_id |
400 |
| Имя депортамента не уникально в пределах parent_id | 409 |
| Депортамент сам себе родитель | 409 |
| Перемещение создаёт цикл в дереве | 409 |
mode=reassign для депортамента с дочерними |
409 |
| Внутренняя ошибка | 500 |
- Уникальность имени в пределах parent. Реализована через два частичных
UNIQUE INDEXв PostgreSQL — для корневых (parent_id IS NULL) и для потомков (parent_id IS NOT NULL). Это надёжнее, чем проверка только на уровне сервиса (защищает от race condition). - Каскадное удаление. На уровне
FOREIGN KEY ... ON DELETE CASCADE. Удаление родителя автоматически уничтожает поддерево и всех сотрудников одним SQL-запросом, без рекурсии в Go. - Детектирование цикла. Перед PATCH-перемещением выполняется
PostgreSQL
WITH RECURSIVE— собирается поддерево перемещаемого узла, и проверяется, не входит ли новый родитель в это поддерево. - PATCH с nullable полем. Для
parent_idв PATCH важно различать «поле не передали» и «передалиnull». Тело декодируется вmap[string]json.RawMessage, и наличие ключа определяется через проверкуokв map. - Graceful shutdown. На
SIGTERM/SIGINTсервер завершает активные запросы в течение 10 секунд. - Retry подключения к Postgres. В compose-окружении API может стартануть до того, как Postgres примет подключения; есть 30 попыток по 1 секунде.
- Pagination на сотрудников при
include_employees=true. - OpenAPI / Swagger спецификация.
- Заменить рекурсивный обход поддерева в Go на одну CTE-выгрузку.
- Дополнительные тесты на уровне сервиса с реальным Postgres
через
testcontainers-go. - Метрики Prometheus, structured tracing.
- Rate limiting на write-эндпоинтах.