🏫 The School›🏛️ Software Architecture›⬡ धडा 04 — Ports and adapters: मध्यभागी नियमपुस्तिका, बाहेर उघडणारी दारे
🖼️ See the drawing + lab 🏠 Course home 🌿 Branch on GitHub ✏️ View source
🖼️ आकृती आणि labThe drawing + lab पूर्ण पानावर उघडा ↗Open full page ↗

⬡ धडा 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 वर्षांच्या मुलाला समजावल्यासारखे

थोडा वेळ मजले विसरा. नियमपुस्तिका इमारतीच्या मध्यभागी असलेल्या शिक्षक-कक्षात ठेवा. 📚 शिक्षक-कक्षाला दारे आहेत, आणि प्रत्येक दारावर त्याला काय हवे आहे हे सांगणारी पाटी आहे:

दारातून कोण येते याची शिक्षक-कक्षाला पर्वा नसते. आज मोठे कागदपत्रांचे कपाट (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

❓ काय

🤔 का

कारण शाळेचे नियम शाळेच्या कारणांसाठी बदलतात, आणि 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 च्या दिशेने मिळवलेली.

⚠️ नेहमीच्या चुका

🏭 प्रत्यक्ष वापरात

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

⬡ Lesson 04 — Ports and adapters: the rule book in the middle, doors facing out

📍 You are here: Lesson 04 of 12 · Previous: lesson-03-layers · Next: lesson-05-ddd


📦 What's in this branch

Lessons 01–03, plus the building turned inside out. Hexagonal architecture (ports and adapters) puts the domain in the middle; the web, the database and email become adapters on the outside, plugged into ports — interfaces the core owns. The rule: dependencies point inward, so the domain has no imports of infrastructure. You check it with ring_violations() and core_leaks() in arch/analyze.py, and you watch a real circular import break in Python. hexagonal() in arch/demo.py.

🧒 Explain like I'm 5

Forget the floors for a moment. Put the rule book in the staff room in the middle of the building. 📚 The staff room has doors, and on every door there is a sign that says what it needs:

The staff room does not care who comes through the door. Today it is the store-room clerk with the big filing cabinet (SQL). In a test, it is Dipika with a sheet of paper (an in-memory store). Tomorrow it could be a new computer. They all fit the door.

These helpers are adapters. They know the staff room. The staff room does not know them. That is the whole trick: the arrows point in.

🗺️ Diagram

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

🗺️ Drawn version + a lab: https://school-edh.pages.dev/software-architecture/lesson-diagrams.html#l04

❓ What

🤔 Why

Because the rules of the school change for school reasons, and the database changes for technology reasons — and they should not drag each other along. A core with no imports of infrastructure can be tested in milliseconds with a fake adapter, run with a different database, or called from a new front door (a mobile app, a batch job) without edits. And the classic stack from lesson 03 fought the adapters; the rings expect them.

🔧 How (in this repo)

RINGS = {"domain": 0, "app": 1, "web": 2, "infra": 2} in arch/analyze.py. ring_violations(g) lists every corridor that goes to a higher ring number (outward). core_leaks(g) lists every domain room importing anything outside the domain. scan() also records outside libraries per room (external), so you can see that only the adapters import sqlite3 and smtplib. FIXES[:3] are the three outward corridors.

🧪 Try it

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

The first snippet imports the shipped sample; the second one works on a copy in a temporary folder, so the sample stays as it is.

✅ Verify — what you should see

hexagonal prints:

── 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

Your two snippets print:

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

🏁 What you just proved

The classic layer check of lesson 03 called infra.db → domain.ports wrong; the rings call it right and flag the opposite direction instead: domain.grades → infra.db. That one outward corridor is also half of a cycle (infra.db imports domain.grades for Mark), and Python really refuses it: importing domain.grades starts infra.db, which asks for Mark before grades has defined it — and by then the failed import has already loaded the sqlite3 driver. Remove the corridor and the report card works with an in-memory adapter — and neither sqlite3 nor smtplib is even loaded. That is testability (lesson 01's driving characteristic), bought by the direction of one import.

⚠️ Common mistakes

🏭 In production

On a real project — an import-linter forbidden contract keeps the core clean, including 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

A port and an adapter in plain Python — the core owns the Protocol, the adapter satisfies it without importing anything the core does not own:

# 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]

In Java and Kotlin the same idea is usually one Gradle or Maven module per ring, so the build refuses an outward import; ArchUnit's onionArchitecture() rule checks it in a test.

🏭 Why this matters in production: time your domain's unit tests. If they need a database container to start, a port is missing — and every business-rule change is paying for it.

⏭️ Next

The core is clean. But inside it, "student" means three different things to three different offices. Departments — bounded contexts.

git checkout lesson-05-ddd
← PreviouslayersNext →ddd

This page is the lesson's README from the lesson-04-hexagonal branch, shown here so the whole School stays on one site. Code files open on GitHub at the same branch.