Найкращі практики Python Django
Тепер уявіть цю ж саму ситуацію, застосовану до програмного забезпечення. Ми, як експерти-програмісти з Python, схожі на шахових майстрів з попереднього прикладу. Коли код не структурований, без дотримання будь-якої логіки чи стандартів, тоді нам буде так само важко помічати помилки, як і початківцю-розробнику. З іншого боку, якщо ми звикли читати код у структурованому вигляді, і ми навчилися швидко схоплювати ідеї з коду, слідуючи шаблонам, то ми маємо значну перевагу.
— «Clean Code in Python», Mariano Anaya
Привіт! Мене звати Андрій, я Python Developer у компанії Starnavi. У розробці ПЗ, попри принцип Zen of Python, не завжди є єдиний очевидний спосіб написання коду. Часто маємо кілька варіантів, і не завжди зрозуміло, який з них кращий. Тому хочу поділитися власним підходом до вирішення деяких архітектурних і стильових питань. Сподіваюся, це допоможе початківцям знайти вектор розвитку або прийняти технічне рішення. Адже навіть зараз у топ-результатах Google можна знайти статті із застарілими практиками.
Business logic
Code blocks should be like Lego. High cohesion — maximum independence.
Найкращим компромісом для реалізації бізнес-логіки в Django є сервісний шар. Це можуть бути як функції, так і класи. У статтях Where to Put Business Logic Django? та Business logic in a Django project описано варіанти розміщення логіки, а також їх плюси та мінуси. Також у Django-Styleguide є приклади сервісів та моделей.
Сервіси реалізують патерн Фасад (Facade Pattern): інкапсулюють складну логіку й надають простий інтерфейс. Це також дозволяє дотримуватись принципу low coupling, high cohesion. Якщо перевірка якоїсь умови відбувається у багатьох місцях — логіку можна винести у property самої моделі.
Якоюсь мірою сервіси можна назвати аналогом інтеракторів (UseCases) з Чистої Архітектури. Uncle Bob каже, що Interactor реалізує use case і має метод запуску execute() і виходить, що це патерн Команда.
Переваги сервісного шару:
- Відокремлення логіки від представлення (views, Celery tasks).
- Менше залежностей між компонентами.
- Простіше тестування й розширення.
- Краща читаність і повторне використання.
Тобто ключова роль сервісу програми полягає в оркестрації сценарію використання, тому високий рівень зчеплення між включеними в сценарій частинами зазвичай очікуваний. Іншими словами, архітектурна мета сервісу полягає в оркестрації логіки, щоб дозволити компонентам нижчого рівня менше зчеплюватися між собою.
Є також аргументи проти такого підходу — Against service layers in Django, More on service layers in Django. Та мені вони видались непереконливими.
Пакет ether-utils містить кілька прийомів, які допоможуть покращити написання сервісів.
Без ether:
DB = {}
class ServiceError(Exception):
...
@dataclass
class UserDTO:
name: str
age: int
email: str
class UpdateUserService:
user_dto: UserDTO
client: Client
def __init__(self, user_dto: UserDTO, client: Client):
self.user_dto = user_dto
self.client = client
def run(self):
log.info("Start creating user service")
log.info("User DTO: %s", self.user_dto)
if self.user_dto.email not in DB:
log.info("User doesn't exists")
raise ServiceError("User doesn't exists")
user = DB[self.user_dto.email]
if self.user_dto.name == user.name:
log.info("User name is the same")
return
# update user logic
Після рефакторингу:
DB = {}
class ServiceError(Exception):
...
@dataclass
class UserDTO:
name: str
age: int
email: str
@service
class UpdateUserSvc:
user_dto: UserDTO
client: Client
@catch_a_break
def run(self):
user = self.get_user()
self.check_name(user)
self.update_user(user)
def get_user(self) -> UserDTO:
if self.user_dto.email not in DB:
log.info("User doesn't exists")
raise ServiceError("User doesn't exists")
return DB[self.user_dto.email]
def check_name(self, user: User) -> None:
if self.user_dto.name == user.name:
# log.info("User name is the same") <- no need - `@catch_a_break` will log
raise Break("User name has the same")
def update_user(self, user: User) -> User:
...
Переваги такого підходу:
- Відсутність методу __init__ — хоча в цьому випадку це просто, у складніших сценаріях з багатьма аргументами такий підхід забезпечує більшу гнучкість.
- Чітка семантика через декоратор @service — він явно позначає клас як сервіс, уникаючи плутанини з @dataclass, який слід використовувати для DTO.
- Автоматичне логування — під час ініціалізації сервісу автоматично логуються передані аргументи (args і kwargs).
- Чистіша структура коду — метод run містить лише послідовність дій, а бізнес-логіка винесена в окремі методи.
- Акуратна обробка помилок зі структурованими логами — операції можуть перериватися коректно з інформативними повідомленнями в логах.
Також при роботі з моделями для choices використовуйте models.TextChoices та models.IntegerChoices. Однак краще їх зберігати в окремому файлі constants.py, а не в моделі, як радить офіційна документація Джанго.
Project structure
Django з коробки пропонує модульну архітектуру, добре придатну для малих і середніх проєктів. Applications в Джанго це свого роду домени в термінах DDD. Вони забезпечують high cohesion. Тобто все, що відноситься до конкретного домену, знаходиться в одному місці — моделі, сервіси, константи. Це допомагає уникати антипаттерну big ball of mud, де все залежить від усього.
Хорошим покращенням стандартної структури ще може бути створення директорії core/base на верхньому рівні, де зберігаються спільні для всього проєкту речі: винятки, поля, абстрактні класи, константи тощо.
DRF
Більшість проєктів на Джанго — це АПІ, де найбільшу популярність здобув Django REST framework. ViewSet-и та Serializer-и чудово підходять для CRUD-операцій. Але при складній логіці ModelSerializer часто перетворюється на антипаттерн: логіка розмазується по всіх шарах, стає важкою для супроводу.
У складних кейсах серіалізатори варто використовувати лише для валідації вхідних даних, тобто клієнтського вводу. Бізнес-правила слід винести у сервісний шар. Так, ми втрачаємо велику частину drf серіалайзера, але отримуємо зрозуміліший код. Також, для того, щоб після валідації серіалайзера отримати об’єкт DTO, можна використати пакет github.com/...restframework-dataclasses. Це схоже на те, як Pydantic використовується в FastAPI.
Для роботи з вкладеними словниками іноді буде зручно використовувати nget та destruct з пакету ether-utils:
>>> data = {'result': {'users': [{'address': {'street': 'Main St'}}]}}
>>> nget(data, 'result', 'users', 0, 'address', 'street')
'Main St'
Avoid mixins
Загальне правило при написанні коду — prefer composition over inheritance. В неті є багато матеріалів на цю тему. Частково я написав про причини тут: composition-vs-inheritance.
Тобто можна сказати, що використання успадкування (через клас mixin) є досить фіктивним. Ми не використовуємо успадкування для створення більш спеціалізованої версії класу, а лише для того, щоб скористатися методом міксіну. Це погано з двох (доповнюючих) причин: по-перше, успадкування — не найкращий спосіб повторного використання коду. Гарний код повторно використовується за допомогою невеликих, цілісних абстракцій, а не створення ієрархій. По-друге, створення підкласу має слідувати з ідеєю спеціалізації, відношення типу «є».
Іноді міксіни справді корисні, і дозволяють реалізувати принцип ISP — Принцип розділення інтерфейсу. Прикладом можуть слугувати mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin в DRF. Але в більшості інших випадків це просто клас з однією-двома функціями або полями.
Явне краще неявного, тому навіть якщо це службові поля в моделі — буде краще вказати їх явно і продублювати в усіх місцях, ніж виносити в окрему модель. Може здатись, що це суперечить DRY, та при зловживанні ми можемо створити складне дерево наслідування, де, щоб дізнатись усі поля моделі, треба обійти кілька рівнів вкладеності. Також це може призводити до конфліктів імен та атрибутів. Тобто заради економії кількох рядків коду ми отримуємо багато потенційних проблем з кодом у майбутньому.
Avoid versioning
Може здатись, що версіонування АПІ — це чудовий вихід. Джанго дозволяє доволі легко додавати нові версії АПІ, але такий код стає дуже складно підтримувати вже в дуже короткій перспективі. Адже щоб впевнитись, що у нас все працює так, як треба, потрібно продублювати усі тести для кожної версії АПІ. В бізнес-логіці в нас починаються складні розгалуження логіки залежно від версій, які можуть навіть суперечити одна одній.
Натомість намагайтесь зберігати зворотну сумісність API. Якщо все ж потрібне versioning — мінімізуйте його використання.
Avoid signals and events
Використання сигналів та подій може бути спокусливим. Так ми ніби зменшуємо coupling, та насправді це тільки збільшує проблеми в розробці.
Сигнали в Джанго є своєрідним антипаттерном. Мало того що вони синхронні, так ще розмазують бізнес-логіку по багатьох шарам та роблять її неочевидною. Деякі пояснення можна прочитати в статтях Django Anti-Patterns: Signals та signals.
Але навіть якби сигнали були асинхронними, Event Driven Architecture все одно слід уникати до останнього. При використанні events інший розробник може не знати про їх існування, і таким чином це робить код менш передбачуваним. Тобто в коді більше немає єдиного місця, де можна подивитись, як проходить увесь flow. Не вийде просто відкрити код і пробігтись очима по потоку виконання.
Це вимагає широкого тестового покриття, яке буде містити усі події та можливі обробники. При змінах потрібно кожен раз розкручувати весь клубок подій, документувати його, підтримувати цю документацію в актуальному стані. І навіть разом з тим — помилки неминучі, адже людський фактор ніхто не відміняв. Це дорого і ненадійно.
Та сама проблема виникає при використанні хореографічної SAGA — немає централізованого місця, де можна переглянути логіку викликів. Тому в SAGA також краще використовувати оркестрацію, а не хореографію — явне краще неявного.
Settings
Краще відразу розділяти налаштування по компонентах — drf, celery, sentry. Є два підходи, як це зробити — або з допомогою пакета django-split-settings, або простими імпортами — from celery import *. Налаштування відповідно потрібно зберігати у змінних середовищах.
Читати можна з допомогою django-environ, який вміє також читати змінні з .env-файла (добрим тоном буде мати цей файл з фейковими необхідними змінним в репозиторії).
Також, якщо потрібно задавати змінні з джанго адмінки, в пригоді стане django-constance.. Більше можна прочитати в статті Configuring Django Settings: Best Practices.
Tests
pytest вже давно став стандартом. Додаткові інструменти для тестування можна знайти у цьому розділі testing, а приклади самих тестів — тут tests, testing.
Хорошою практикою є написати
У CI/CD рекомендую використовувати xdist для паралельного запуску тестів та coverage для вимірювання покриття. Спробуйте досягати 100% покриття — не шляхом тестування кожного рядка, а за потреби, позначаючи непридатний до тестування код директивою # pragma: no cover. Додавши перевірку покриття у CI/CD, ви стимулюєте написання тестів одразу, що зменшує технічний борг і прискорює виявлення помилок.
Linting, formatting, type checking, package management
Black та flake8 вірою і правдою служили нам багато років, як і poetry. На зміну їм приходять ruff і uv — сучасні, швидкі й надійні інструменти. Замість mypy чекаємо ty.
Хоча pre-commit все ще не втрачає своєї актуальності. GitHub Actions дозволяє легко інтегрувати всі ці перевірки у CI/CD-процеси.
Avoid UUID for id
Як реалізувати пагінацію за uuid? Ніяк, оскільки UUID не мають природного порядку, як автоінкрементні цілочисельні ідентифікатори (за винятком uuid7). Також є ряд інших проблем. Детальніше можна ознайомитись за посиланням uuid. UUID все ще має свої застосування, але не слід вважати його універсальним рішенням або використовувати як ідентифікатор за замовчуванням.
Dependency Injection (DI)
Суть DI полягає в тому, що клас не створює свої залежності самостійно, а отримує їх ззовні — через конструктор або метод (наприклад, Logger() всередині конструктора). Це підвищує тестованість, гнучкість і полегшує зміну реалізацій.
class Logger:
def log(self, message):
print(f"[LOG]: {message}")
class UserService:
def __init__(self, logger):
self.logger = logger
def register_user(self, username):
self.logger.log(f"Користувача '{username}' зареєстровано.")
# usage
logger = Logger()
service = UserService(logger)
service.register_user("andriy_92")
# in tests
class MockLogger:
def log(self, message):
self.last_message = message # can check in tests
Більше прикладів можна знайти в документації до одного з популяних
DDD, Clean Architecture
This is an important point: remember that code is for us, people, to understand, so only we can determine what is good or bad code. We should invest time in code reviews, thinking about what is good code, and how readable and understandable it is. When looking at the code written by a peer, you should ask such questions as:
— Is this code easy to understand and follow to a fellow programmer?
— Does it speak in terms of the domain of the problem?
— Would a new person joining the team be able to understand it, and work with it effectively?
— «Clean Code in Python», Mariano Anaya
DDD та Clean Architecture можуть бути корисними для будівництва справді великих монолітів, де рано чи пізно виникають проблеми з high coupling, коли зміни в одному модулі призводять до багів в іншому, адже бізнес-логіки надто багато, і вона розкидана по різним місцям. Це стає меншою проблемою в епоху компактних мікросервісів.
Також іноді розробники настільки захоплюються створенням розширюваної архітектури, що система стає надмірно складною й важкою для розуміння навіть для простих проєктів. Якщо абстракція створює більше проблем, ніж вирішує, варто переглянути підхід і не використовувати її. Як казав Альберт Ейнштейн: все має бути зроблено настільки просто, наскільки це можливо, але не простіше. Корисна стаття на цю тему — Overengineering in Onion/Hexagonal Architectures.
Інша проблема — rich domain model з DDD. Це підхід, коли вся бізнес-логіка зосереджена безпосередньо в об’єктах доменної моделі. Тобто об’єкти не лише зберігають дані, а й самі знають, як з цими даними працювати. Проте без достатнього досвіду це призводить до появи так званих Fat Models або God Objects, які ми вже згадували раніше.
Альтернативою може бути поєднання ідей Clean Architecture, DDD та Vertical Slice Architecture. Наприклад, у статті Maybe it’s time to rethink our project structure with .NET 6 код пропонується групувати за модулями як в стандартному підході Джанго. Все нове — добре забуте старе.
Resources
- Django coding style — docs.djangoproject.com
- Clean Code in Python — Mariano Anaya
- Django-Styleguide — github.com
- Working with Django Models in Python: Best Practices — djangostars.com
- Configuring Django Settings: Best Practices — djangostars.com
- Best Django security practices — escape.tech
- clean-code-python — github.com
- The Clean Architecture in Python. How to write testable and flexible code — breadcrumbscollector.tech
- awesome_pj — github.com
- ether
- Python Interview Questions and Answers
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

17 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів