⬡ धडा 04 — Ports and adapters: मध्यभागी नियमपुस्तिका, बाहेर उघडणारी दारे
📍 तुम्ही इथे आहात: 12 पैकी धडा 04 · मागे: lesson-03-layers · पुढे: lesson-05-ddd
📦 या ब्रँचमध्ये काय आहे
धडे 01–03, आणि आतून बाहेर उलटवलेली इमारत. Hexagonal architecture (ports
and adapters) domain ला मध्यभागी ठेवते; web, database आणि email हे बाहेरचे adapters
बनतात, जे ports ना जोडले जातात — core च्या मालकीचे interfaces. नियम:
dependencies आतल्या दिशेने जातात, म्हणून domain मध्ये infrastructure चा कोणताही import
नसतो. तुम्ही हे arch/analyze.py मधील ring_violations() आणि
core_leaks() ने तपासता, आणि Python मध्ये एक खरा circular import मोडताना पाहता.
arch/demo.py मधील hexagonal().
🧒 5 वर्षांच्या मुलाला समजावल्यासारखे
थोडा वेळ मजले विसरा. नियमपुस्तिका इमारतीच्या मध्यभागी असलेल्या शिक्षक-कक्षात ठेवा. 📚 शिक्षक-कक्षाला दारे आहेत, आणि प्रत्येक दारावर त्याला काय हवे आहे हे सांगणारी पाटी आहे:
- "मला marks आणून देणारे कोणीतरी हवे." (एक port)
- "मला पत्र पाठवू शकणारे कोणीतरी हवे." (एक port)
दारातून कोण येते याची शिक्षक-कक्षाला पर्वा नसते. आज मोठे कागदपत्रांचे कपाट (SQL) सांभाळणारी भांडारातली कारकून येते. Test मध्ये, कागदाचा एक तुकडा घेऊन दीपिका येते (an in-memory store). उद्या एखादा नवीन computer असू शकतो. ते सगळे दाराला बसतात.
हे मदतनीस म्हणजे adapters. त्यांना शिक्षक-कक्ष माहीत असतो. शिक्षक-कक्षाला ते माहीत नसतात. हीच सगळी युक्ती: बाण आत जातात.
🗺️ आकृती
flowchart LR
subgraph outside["🔌 adapters (outside ring)"]
web["🖥️ web.views<br/>driving adapter"]
db["🗄️ infra.db<br/>SqlGradeStore, SqlLedger"]
mail["✉️ infra.email<br/>SmtpMailer"]
end
subgraph middle["🧑🏫 app (use cases)"]
rc["app.report_card"]
en["app.enrol"]
end
subgraph core["📚 domain (the core)"]
ports["domain.ports<br/>GradeStore · Mailer · Ledger"]
grades["domain.grades"]
end
web --> rc
rc --> ports
db -->|"implements"| ports
mail -->|"implements"| ports
grades -.->|"❌ outward: grades → infra.db"| db
en -.->|"❌ outward: enrol → infra.email"| mail
🗺️ काढलेली आवृत्ती + एक lab: https://school-edh.pages.dev/software-architecture/lesson-diagrams.html#l04
❓ काय
- Hexagonal architecture / ports and adapters — Alistair Cockburn यांचा pattern (2005). Application core भोवती ports असतात; बाहेरचे जग adapters मार्फत जोडले जाते. षट्कोनाचा आकार म्हणजे फक्त "अनेक बाजू, अनेक ports" — सहा या आकड्यात काही विशेष नाही.
- Port — core च्या मालकीचा, core च्याच शब्दांतला interface:
domain/ports.pyमधीलGradeStore,Mailer,Ledger(एकtyping.Protocolकिंवा एकabc.ABC). - Adapter — port ला खऱ्या technology शी जोडणारा code:
SqlGradeStore(SQL),SmtpMailer(SMTP). Driving (primary) adapters core मध्ये call करतात — एक web handler, एक CLI, एक test. Driven (secondary) adapters ना core port मार्फत call करतो — एक database, एक mail server. - Dependency inversion — SOLID मधला "D": high-level policy low-level तपशिलावर अवलंबून नसते;
दोघेही एका abstraction वर अवलंबून असतात, आणि ते abstraction policy च्या मालकीचे असते.
infra.dbdomain.portsला import करतो, उलट नाही. - Dependency rule (rings) — Onion architecture (Jeffrey Palermo, 2008) आणि Clean
Architecture (Robert C. Martin) हीच कल्पना वर्तुळांनी काढतात: source code dependencies
फक्त आत जातात. Lab तीन rings वापरतो:
domain0,app1,webआणिinfra2. - Composition root — प्रत्येक concrete class ओळखणारी आणि त्यांना जोडणारी एकमेव जागा
(
schoolapp/main.py). ती मुद्दाम प्रत्येक ring च्या बाहेर बसते. - Circular import — एकमेकांना import करणारे दोन modules. Python मध्ये cycle च्या आतला
from X import NameहाImportError: cannot import name … from partially initialized module …देऊन fail होऊ शकतो, कारणXचे चालणे अजून संपलेले नसते.
🤔 का
कारण शाळेचे नियम शाळेच्या कारणांसाठी बदलतात, आणि database technology च्या कारणांसाठी बदलतो — आणि त्यांनी एकमेकांना फरफटत नेऊ नये. Infrastructure चा कोणताही import नसलेला core, fake adapter सोबत काही ms मध्ये test होऊ शकतो, वेगळ्या database सोबत चालू शकतो, किंवा नव्या पुढच्या दारातून (mobile app, batch job) कोणताही बदल न करता call होऊ शकतो. आणि धडा 03 मधली पारंपरिक रचना adapters शी लढत होती; rings त्यांची अपेक्षा करतात.
🔧 कसे (या repo मध्ये)
arch/analyze.py मध्ये RINGS = {"domain": 0, "app": 1, "web": 2, "infra": 2}.
ring_violations(g) जास्त ring क्रमांकाकडे (बाहेर) जाणारी प्रत्येक मार्गिका सूचीबद्ध करतो.
core_leaks(g) domain बाहेरचे काहीही import करणारी प्रत्येक domain खोली सूचीबद्ध करतो. scan()
प्रत्येक खोलीसाठी बाहेरच्या libraries (external) ही नोंदवतो, म्हणजे फक्त adapters च
sqlite3 आणि smtplib import करतात हे तुम्हाला दिसते. FIXES[:3] म्हणजे बाहेर जाणाऱ्या
तीन मार्गिका.
🧪 करून पाहा
python3 arch/demo.py hexagonal
python3 - <<'EOF'
import sys; sys.path.insert(0, "arch/sample")
try:
import schoolapp.domain.grades
except ImportError as e:
print("as shipped →", type(e).__name__ + ":", str(e).split(" (")[0])
print("database driver already loaded:", "sqlite3" in sys.modules)
EOF
python3 - <<'EOF'
import os, shutil, sys, tempfile
d = tempfile.mkdtemp(); shutil.copytree("arch/sample/schoolapp", os.path.join(d, "schoolapp"))
p = os.path.join(d, "schoolapp", "domain", "grades.py")
src = open(p).read().replace("from schoolapp.infra.db import connect", "") # remove the outward corridor
open(p, "w").write(src)
sys.path.insert(0, d)
from schoolapp.domain.grades import Mark
from schoolapp.app.report_card import ReportCardService
class InMemoryStore: # a test adapter for the GradeStore port
def marks_for(self, roll_no): return [Mark(roll_no, "maths", 88), Mark(roll_no, "science", 91)]
print("fixed →", ReportCardService(InMemoryStore()).card_for(7))
print("database driver loaded:", "sqlite3" in sys.modules, "· mail library loaded:", "smtplib" in sys.modules)
EOF
पहिला snippet shipped sample import करतो; दुसरा एका तात्पुरत्या folder मधील copy वर काम करतो, म्हणजे sample आहे तसाच राहतो.
✅ तपासा — तुम्हाला काय दिसायला हवे
hexagonal हे छापतो:
── rings: domain (core) ← app ← web + infra (adapters) · dependencies point INWARD only
outward: app.enrol → infra.email
outward: domain.grades → infra.db
outward: domain.timetable → web.format
the domain imports outside itself 2 times · outside libraries per room: domain.ports ['abc', 'typing'], infra.db ['sqlite3'], infra.email ['smtplib']
to test domain.grades you must load 3 other rooms ['domain.admissions', 'domain.ports', 'infra.db']
remove the 3 outward corridors (use the ports) → 1 ['domain.admissions'] · outward left: 0
तुमचे दोन snippets हे छापतात:
as shipped → ImportError: cannot import name 'Mark' from partially initialized module 'schoolapp.domain.grades'
database driver already loaded: True
fixed → {'maths': 88, 'science': 91}
database driver loaded: False · mail library loaded: False
🏁 तुम्ही आत्ताच काय सिद्ध केले
धडा 03 च्या पारंपरिक layer check ने infra.db → domain.ports ला चुकीचे म्हटले; rings त्याला
बरोबर म्हणतात आणि त्याऐवजी उलट दिशेवर बोट ठेवतात: domain.grades → infra.db.
बाहेर जाणारी ती एक मार्गिका एका cycle चा अर्धा भागही आहे (infra.db Mark साठी
domain.grades import करतो), आणि Python खरोखर तो नाकारतो: domain.grades import केले की
infra.db सुरू होतो, जो grades ने Mark define करण्याआधीच तो मागतो — आणि तोपर्यंत fail
झालेल्या import ने sqlite3 driver आधीच load केलेला असतो. ती मार्गिका काढा आणि report card
in-memory adapter सोबत चालते — आणि sqlite3 किंवा smtplib दोन्हीपैकी काहीच load होत
नाही. हीच testability (धडा 01 चे driving characteristic), एका import च्या दिशेने मिळवलेली.
⚠️ नेहमीच्या चुका
- technology च्या नावाने दिलेले ports (
SqlRepository) — मग core अजूनही SQL मध्ये विचार करतो - ORM models domain objects म्हणून वापरणे, म्हणजे core ORM import करतो
- use cases ना port मिळण्याऐवजी ते स्वतःचे adapters बनवतात (
app.enrolमध्येSmtpMailer(...)) - प्रत्येक table साठी एक port, डझनभर methods सह — port ने core ची भाषा बोलायला हवी
- असा "hexagon" जिथे प्रत्येक adapter अजूनही domain "फक्त types साठी" import करतो
🏭 प्रत्यक्ष वापरात
On a real project — import-linter चा forbidden contract core स्वच्छ ठेवतो, third-party libraries सकट:
[importlinter]
root_package = schoolapp
include_external_packages = True
[importlinter:contract:core]
name = The domain imports no adapters and no drivers
type = forbidden
source_modules =
schoolapp.domain
forbidden_modules =
schoolapp.infra
schoolapp.web
sqlalchemy
requests
साध्या Python मधला एक port आणि एक adapter — Protocol core च्या मालकीचा आहे, आणि adapter
core च्या मालकीचे नसलेले काहीही import न करता तो पूर्ण करतो:
# domain/ports.py (the core)
from typing import Protocol
class GradeStore(Protocol):
def marks_for(self, roll_no: int) -> list["Mark"]: ...
# tests/test_report_card.py (a driving adapter: the test)
class InMemoryStore:
def __init__(self, marks): self.marks = marks
def marks_for(self, roll_no): return [m for m in self.marks if m.roll_no == roll_no]
Java आणि Kotlin मध्ये हीच कल्पना सहसा प्रत्येक ring साठी एक Gradle किंवा Maven module अशी
असते, म्हणजे build च बाहेर जाणारा import नाकारतो; ArchUnit चा onionArchitecture() नियम
ते एका test मध्ये तपासतो.
🏭 प्रत्यक्ष वापरात हे का महत्त्वाचे: तुमच्या domain च्या unit tests ना किती वेळ लागतो ते मोजा. त्यांना सुरू व्हायला database container लागत असेल, तर एक port गहाळ आहे — आणि business rule मधला प्रत्येक बदल त्याची किंमत मोजतो आहे.
⏭️ पुढे
Core स्वच्छ आहे. पण त्याच्या आत, "student" चा अर्थ तीन वेगवेगळ्या offices साठी तीन वेगवेगळ्या गोष्टी आहे. विभाग — bounded contexts.
git checkout lesson-05-ddd