[{"data":1,"prerenderedAt":631},["ShallowReactive",2],{"blog":3},[4,285,498],{"id":5,"title":6,"body":7,"date":274,"description":275,"extension":276,"meta":277,"navigation":143,"path":278,"seo":279,"stem":280,"tags":281,"__hash__":284},"blog/blog/domain-composed-models.md","Domain-Composed Models: the shape most teams converge to anyway",{"type":8,"value":9,"toc":268},"minimark",[10,15,19,22,25,29,49,55,58,62,65,75,81,91,227,231,234,247,250,253,264],[11,12,14],"h2",{"id":13},"a-pattern-without-a-name","A pattern without a name",[16,17,18],"p",{},"Over the past few years, working across different teams and codebases, I kept noticing the same thing: engineers who had tried Clean DDD and retreated, and engineers who had outgrown Active Record, both ended up writing code that looked roughly the same. A domain model that held behaviour. Infrastructure dependencies pointing inward. No full ports-and-adapters ceremony, but not an ORM model with business logic sprinkled into controllers either.",[16,20,21],{},"Nobody called it anything. It was just \"how we write code here.\"",[16,23,24],{},"I started calling it Domain-Composed Models (DCM), not because it needed branding, but because naming a pattern makes it easier to teach, debate, and deliberately choose.",[11,26,28],{"id":27},"the-problem-with-the-extremes","The problem with the extremes",[16,30,31,35,36,40,41,44,45,48],{},[32,33,34],"strong",{},"Active Record"," is fast to start. The model knows how to persist itself, query itself, and often validate itself. For simple CRUD-heavy applications it is genuinely appropriate. The problem arrives when the domain gets complex: business rules start accumulating in service classes, the model becomes anemic, and you end up with ",[37,38,39],"code",{},"UserManager",", ",[37,42,43],{},"OrderProcessor",", and ",[37,46,47],{},"PaymentService"," each holding fragments of logic that belong together.",[16,50,51,54],{},[32,52,53],{},"Clean DDD"," solves this. Domain logic lives in rich entities and aggregates, completely isolated from infrastructure concerns behind repository interfaces and application service layers. It is correct. It is also expensive — expensive to learn, expensive to set up, expensive to maintain when the problem does not justify the ceremony.",[16,56,57],{},"The friction point is real: most teams are not building a financial trading system or a complex insurance domain. They are building a SaaS product with meaningful business rules that deserve better than anemic models, but not a full hexagonal architecture.",[11,59,61],{"id":60},"what-dcm-looks-like","What DCM looks like",[16,63,64],{},"The pattern has three properties:",[16,66,67,70,71,74],{},[32,68,69],{},"Domain behaviour lives on the model."," Not in service classes. A ",[37,72,73],{},"Subscription"," knows how to renew, cancel, and check its own eligibility. This eliminates anemia without requiring an aggregate root hierarchy.",[16,76,77,80],{},[32,78,79],{},"Infrastructure goes one way."," The domain model does not import from the ORM, the messaging layer, or the database client. Persistence adapters depend on domain objects, not the reverse. This is lighter than full Clean DDD — there are no repository interfaces — but it preserves the directional constraint that makes domain logic independently testable.",[16,82,83,86,87,90],{},[32,84,85],{},"Composition over inheritance for cross-cutting concerns."," Instead of a base ",[37,88,89],{},"ActiveRecord"," class that drags infrastructure into every model, concerns like audit trails, soft deletion, or event emission are mixed in explicitly where they are needed.",[92,93,98],"pre",{"className":94,"code":95,"language":96,"meta":97,"style":97},"language-python shiki shiki-themes github-light github-dark","# A DCM-style subscription model (Python/SQLAlchemy flavour)\nclass Subscription:\n    def __init__(self, plan: Plan, started_at: datetime):\n        self.plan = plan\n        self.started_at = started_at\n        self.status = SubscriptionStatus.ACTIVE\n\n    def renew(self, as_of: datetime) -> \"Subscription\":\n        if not self.plan.allows_renewal(as_of):\n            raise RenewalNotAllowed(self.plan, as_of)\n        return Subscription(plan=self.plan, started_at=as_of)\n\n    def cancel(self, reason: CancellationReason) -> None:\n        self.status = SubscriptionStatus.CANCELLED\n        self._record(SubscriptionCancelled(reason=reason))\n\n# Persistence is a separate concern that depends on Subscription,\n# not the other way around\nclass SubscriptionRepository:\n    def save(self, subscription: Subscription) -> None: ...\n    def find_by_id(self, id: UUID) -> Subscription | None: ...\n","python","",[37,99,100,108,114,120,126,132,138,145,151,157,163,169,174,180,186,192,197,203,209,215,221],{"__ignoreMap":97},[101,102,105],"span",{"class":103,"line":104},"line",1,[101,106,107],{},"# A DCM-style subscription model (Python/SQLAlchemy flavour)\n",[101,109,111],{"class":103,"line":110},2,[101,112,113],{},"class Subscription:\n",[101,115,117],{"class":103,"line":116},3,[101,118,119],{},"    def __init__(self, plan: Plan, started_at: datetime):\n",[101,121,123],{"class":103,"line":122},4,[101,124,125],{},"        self.plan = plan\n",[101,127,129],{"class":103,"line":128},5,[101,130,131],{},"        self.started_at = started_at\n",[101,133,135],{"class":103,"line":134},6,[101,136,137],{},"        self.status = SubscriptionStatus.ACTIVE\n",[101,139,141],{"class":103,"line":140},7,[101,142,144],{"emptyLinePlaceholder":143},true,"\n",[101,146,148],{"class":103,"line":147},8,[101,149,150],{},"    def renew(self, as_of: datetime) -> \"Subscription\":\n",[101,152,154],{"class":103,"line":153},9,[101,155,156],{},"        if not self.plan.allows_renewal(as_of):\n",[101,158,160],{"class":103,"line":159},10,[101,161,162],{},"            raise RenewalNotAllowed(self.plan, as_of)\n",[101,164,166],{"class":103,"line":165},11,[101,167,168],{},"        return Subscription(plan=self.plan, started_at=as_of)\n",[101,170,172],{"class":103,"line":171},12,[101,173,144],{"emptyLinePlaceholder":143},[101,175,177],{"class":103,"line":176},13,[101,178,179],{},"    def cancel(self, reason: CancellationReason) -> None:\n",[101,181,183],{"class":103,"line":182},14,[101,184,185],{},"        self.status = SubscriptionStatus.CANCELLED\n",[101,187,189],{"class":103,"line":188},15,[101,190,191],{},"        self._record(SubscriptionCancelled(reason=reason))\n",[101,193,195],{"class":103,"line":194},16,[101,196,144],{"emptyLinePlaceholder":143},[101,198,200],{"class":103,"line":199},17,[101,201,202],{},"# Persistence is a separate concern that depends on Subscription,\n",[101,204,206],{"class":103,"line":205},18,[101,207,208],{},"# not the other way around\n",[101,210,212],{"class":103,"line":211},19,[101,213,214],{},"class SubscriptionRepository:\n",[101,216,218],{"class":103,"line":217},20,[101,219,220],{},"    def save(self, subscription: Subscription) -> None: ...\n",[101,222,224],{"class":103,"line":223},21,[101,225,226],{},"    def find_by_id(self, id: UUID) -> Subscription | None: ...\n",[11,228,230],{"id":229},"when-to-use-it","When to use it",[16,232,233],{},"DCM earns its place when:",[235,236,237,241,244],"ul",{},[238,239,240],"li",{},"The domain has real rules that get violated when logic scatters into service classes",[238,242,243],{},"The team is comfortable with OOP but not prepared to invest in a full hexagonal architecture",[238,245,246],{},"Testing matters — domain behaviour should be testable without database fixtures",[16,248,249],{},"It is not appropriate for simple CRUD operations where the \"domain\" is really just data shapes. Active Record is fine there. The right amount of structure is always determined by the actual complexity of the problem, not by cargo-culting patterns from domains with different constraints.",[251,252],"hr",{},[16,254,255,256,263],{},"The full writeup with a comparison table across Active Record, DCM, and Clean DDD is ",[257,258,262],"a",{"href":259,"rel":260},"https://hamza-senhajirhazi.medium.com/domain-composed-models-dcm-a-pragmatic-middle-ground-between-active-record-and-clean-ddd-e44172a58246",[261],"nofollow","on Medium",".",[265,266,267],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":97,"searchDepth":110,"depth":110,"links":269},[270,271,272,273],{"id":13,"depth":110,"text":14},{"id":27,"depth":110,"text":28},{"id":60,"depth":110,"text":61},{"id":229,"depth":110,"text":230},"2026-01-15","Clean DDD is too much ceremony. Active Record becomes too coupled. After watching teams independently land on the same middle ground, it deserved a name.","md",{},"/blog/domain-composed-models",{"title":6,"description":275},"blog/domain-composed-models",[282,283],"architecture","software design","2xSGXkeE6-UBZLsVNYQe4snx2lnZL5d9uAX9tYcK84k",{"id":286,"title":287,"body":288,"date":488,"description":489,"extension":276,"meta":490,"navigation":143,"path":491,"seo":492,"stem":493,"tags":494,"__hash__":497},"blog/blog/dbt-bdd-financial-reporting.md","Financial reporting pipelines don't have a testing problem — they have a contract problem",{"type":8,"value":289,"toc":482},[290,294,297,300,303,307,310,323,326,330,333,336,456,459,463,466,469,471,479],[11,291,293],{"id":292},"the-gap-nobody-names","The gap nobody names",[16,295,296],{},"Walk into a data engineering team at a bank or insurer, and you'll find tests. Usually plenty of them. Schema checks, row-count assertions, null validations. The pipeline goes green, the report ships, and the compliance officer signs off.",[16,298,299],{},"Until it doesn't. Until someone notices that a transformation quietly dropped a product category three months ago, or that a rounding convention changed between two DBT models without anyone realising it had business significance.",[16,301,302],{},"The tests were passing. The business expectation was violated. These are not the same thing.",[11,304,306],{"id":305},"what-bdd-actually-changes","What BDD actually changes",[16,308,309],{},"Behaviour-Driven Development is often introduced as a testing methodology. That framing misses the point — especially in data contexts.",[16,311,312,313,40,316,40,319,322],{},"BDD's actual contribution is a forcing function for making implicit domain knowledge explicit before any code is written. When you describe expected behaviour in Gherkin — ",[37,314,315],{},"Given",[37,317,318],{},"When",[37,320,321],{},"Then"," — you are obliging a business analyst and an engineer to agree, in writing, on what a transformation is supposed to mean. Not just whether it runs.",[16,324,325],{},"In financial reporting, that distinction is not a nice-to-have. It's an audit requirement.",[11,327,329],{"id":328},"what-this-looks-like-in-dbt","What this looks like in DBT",[16,331,332],{},"DBT's test architecture maps onto BDD naturally. A schema test expresses a constraint. A singular test expresses a scenario. The gap is usually that teams write their tests after the model, inferring constraints from the implementation rather than deriving the implementation from stated requirements.",[16,334,335],{},"The reversal matters:",[92,337,341],{"className":338,"code":339,"language":340,"meta":97,"style":97},"language-yaml shiki shiki-themes github-light github-dark","# Not this — a post-hoc constraint check\nmodels:\n  - name: regulatory_capital_report\n    columns:\n      - name: exposure_amount\n        tests:\n          - not_null\n\n# But this — a named expectation with business provenance\ntests:\n  - name: exposure_amount_includes_off_balance_sheet_items\n    description: >\n      Per Basel III Article 111, off-balance-sheet items must be\n      converted to credit exposure equivalents before aggregation.\n","yaml",[37,342,343,349,359,374,381,393,400,408,412,417,424,435,446,451],{"__ignoreMap":97},[101,344,345],{"class":103,"line":104},[101,346,348],{"class":347},"sJ8bj","# Not this — a post-hoc constraint check\n",[101,350,351,355],{"class":103,"line":110},[101,352,354],{"class":353},"s9eBZ","models",[101,356,358],{"class":357},"sVt8B",":\n",[101,360,361,364,367,370],{"class":103,"line":116},[101,362,363],{"class":357},"  - ",[101,365,366],{"class":353},"name",[101,368,369],{"class":357},": ",[101,371,373],{"class":372},"sZZnC","regulatory_capital_report\n",[101,375,376,379],{"class":103,"line":122},[101,377,378],{"class":353},"    columns",[101,380,358],{"class":357},[101,382,383,386,388,390],{"class":103,"line":128},[101,384,385],{"class":357},"      - ",[101,387,366],{"class":353},[101,389,369],{"class":357},[101,391,392],{"class":372},"exposure_amount\n",[101,394,395,398],{"class":103,"line":134},[101,396,397],{"class":353},"        tests",[101,399,358],{"class":357},[101,401,402,405],{"class":103,"line":140},[101,403,404],{"class":357},"          - ",[101,406,407],{"class":372},"not_null\n",[101,409,410],{"class":103,"line":147},[101,411,144],{"emptyLinePlaceholder":143},[101,413,414],{"class":103,"line":153},[101,415,416],{"class":347},"# But this — a named expectation with business provenance\n",[101,418,419,422],{"class":103,"line":159},[101,420,421],{"class":353},"tests",[101,423,358],{"class":357},[101,425,426,428,430,432],{"class":103,"line":165},[101,427,363],{"class":357},[101,429,366],{"class":353},[101,431,369],{"class":357},[101,433,434],{"class":372},"exposure_amount_includes_off_balance_sheet_items\n",[101,436,437,440,442],{"class":103,"line":171},[101,438,439],{"class":353},"    description",[101,441,369],{"class":357},[101,443,445],{"class":444},"szBVR",">\n",[101,447,448],{"class":103,"line":176},[101,449,450],{"class":372},"      Per Basel III Article 111, off-balance-sheet items must be\n",[101,452,453],{"class":103,"line":182},[101,454,455],{"class":372},"      converted to credit exposure equivalents before aggregation.\n",[16,457,458],{},"The second form is a contract. The first is a guard rail. Both have their place; only one of them tells a regulator what you intended.",[11,460,462],{"id":461},"the-platform-angle","The platform angle",[16,464,465],{},"This matters to platform engineering because data pipelines in regulated industries are shared infrastructure. Multiple product teams consume the same transformation layer. When the logic is implicit — when the \"why\" behind a transformation lives in a Jira ticket from 2019 and the memory of one senior analyst — the pipeline cannot safely evolve.",[16,467,468],{},"Codifying the business rules as BDD specs doesn't just improve test quality. It transforms the pipeline from a black box that happens to produce correct output into a documented contract that anyone can challenge, verify, and extend. That's the difference between infrastructure that teams depend on because it works and infrastructure that teams can rely on because they understand it.",[251,470],{},[16,472,473,474,263],{},"The full implementation — including a working DBT project with BDD-style tests wired to real financial reporting scenarios — is on ",[257,475,478],{"href":476,"rel":477},"https://github.com/Senhaji-Rhazi-Hamza/dbt-bdd",[261],"GitHub",[265,480,481],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":97,"searchDepth":110,"depth":110,"links":483},[484,485,486,487],{"id":292,"depth":110,"text":293},{"id":305,"depth":110,"text":306},{"id":328,"depth":110,"text":329},{"id":461,"depth":110,"text":462},"2025-07-10","Most data teams test whether their pipelines run. Few test whether they express the right thing. In regulated environments, that distinction is the whole game.",{},"/blog/dbt-bdd-financial-reporting",{"title":287,"description":489},"blog/dbt-bdd-financial-reporting",[495,496],"data engineering","platform engineering","jMDMnz7ePU_tjsHT7Dj8B4dkpDxOdPhKNLYsJvDMsjI",{"id":499,"title":500,"body":501,"date":621,"description":622,"extension":276,"meta":623,"navigation":143,"path":624,"seo":625,"stem":626,"tags":627,"__hash__":630},"blog/blog/full-infra-stack-pulumi-pyinfra-kubernetes.md","One repository, full stack: infrastructure that teams can actually reproduce",{"type":8,"value":502,"toc":614},[503,507,510,513,516,520,523,533,550,556,560,563,566,569,572,576,584,595,599,602,605,607],[11,504,506],{"id":505},"the-reproducibility-gap","The reproducibility gap",[16,508,509],{},"Most infrastructure starts as someone's working setup. A cluster that was provisioned by hand by the engineer who set it up six months ago. A deployment process that lives in a Slack thread of curl commands. An environment that works on staging but behaves differently in production because the configuration diverged and nobody noticed.",[16,511,512],{},"The tooling to fix this exists. The problem is that it usually requires committing to a managed platform — a cloud provider's Kubernetes service, a CI/CD SaaS, a secrets manager with its own API surface. Each of those choices is a dependency. Some of them are justified. Some of them quietly make it harder to move, audit, or reproduce the environment when it matters.",[16,514,515],{},"This post is about a different shape: a single repository that owns the full stack from VM provisioning to running application, with Python all the way down and no mandatory managed services.",[11,517,519],{"id":518},"the-stack","The stack",[16,521,522],{},"Three tools, composable and independently replaceable:",[16,524,525,532],{},[32,526,527],{},[257,528,531],{"href":529,"rel":530},"https://www.pulumi.com/",[261],"Pulumi"," handles cloud resource provisioning. Instead of YAML or HCL, you write Python. The same language your application uses, the same review process, the same version control. Provisioning four Linux machines on GCP becomes a function call, not a configuration file you maintain separately from everything else.",[16,534,535,542,543,40,546,549],{},[32,536,537],{},[257,538,541],{"href":539,"rel":540},"https://pyinfra.com/",[261],"Pyinfra"," handles configuration management. It is Ansible, but the playbooks are Python. No YAML DSL to learn, no Jinja templating edge cases, no context-switching between the infrastructure code and the application code. Pyinfra connects to the machines Pulumi provisioned and installs the Kubernetes cluster from scratch — ",[37,544,545],{},"kubeadm",[37,547,548],{},"flannel",", an nginx ingress controller, the full setup.",[16,551,552,555],{},[32,553,554],{},"Kubernetes manifests"," deploy the application. Standard, portable, not tied to any particular managed Kubernetes offering.",[11,557,559],{"id":558},"why-this-matters-for-platform-teams","Why this matters for platform teams",[16,561,562],{},"The obvious argument for this pattern is developer independence: a team that can provision, configure, and deploy without filing a ticket to an infrastructure team or clicking through a cloud console is a faster team.",[16,564,565],{},"But the deeper argument is about what reproducibility enables.",[16,567,568],{},"When your infrastructure is code — real code, not configuration prose — it can be reviewed, tested, and version-controlled like any other engineering artefact. You can see exactly what changed between the environment that works and the environment that doesn't. You can reproduce the production environment locally for debugging. You can hand it to a new team member and have them running a full stack in an afternoon.",[16,570,571],{},"That last point is the one that matters most in practice. Infrastructure that only one person understands is a bottleneck with a countdown timer. Infrastructure expressed as a documented, executable codebase is something a team can own collectively.",[11,573,575],{"id":574},"the-repository-structure","The repository structure",[92,577,582],{"className":578,"code":580,"language":581},[579],"language-text","├── infra/\n│   ├── pulumi/          # VM provisioning (Python)\n│   └── pyinfra/         # Cluster configuration (Python)\n├── k8s/                 # Kubernetes manifests\n├── app/                 # Application code\n├── Pulumi.yaml\n└── pyproject.toml       # Unified dependency management with Poetry\n","text",[37,583,580],{"__ignoreMap":97},[16,585,586,587,590,591,594],{},"Everything in one place. ",[37,588,589],{},"git clone",", install dependencies, run Pulumi to get the machines, run Pyinfra to get the cluster, ",[37,592,593],{},"kubectl apply"," to get the application. The entire environment is reproducible by anyone with the credentials.",[11,596,598],{"id":597},"what-this-is-not","What this is not",[16,600,601],{},"This is not an argument against managed Kubernetes, or against Terraform, or against the cloud platforms' own deployment tooling. Those choices are appropriate for many teams and many contexts.",[16,603,604],{},"It is an argument that infrastructure should be owned by the teams who use it, expressed in a form they can read and modify, and reproducible without institutional knowledge. Whether you get there with this stack or another one is secondary.",[251,606],{},[16,608,609,610,263],{},"The full working repository — including the Pulumi provisioning code, Pyinfra playbooks, Kubernetes manifests, and a sample Python application — is on ",[257,611,478],{"href":612,"rel":613},"https://github.com/Senhaji-Rhazi-Hamza",[261],{"title":97,"searchDepth":110,"depth":110,"links":615},[616,617,618,619,620],{"id":505,"depth":110,"text":506},{"id":518,"depth":110,"text":519},{"id":558,"depth":110,"text":559},{"id":574,"depth":110,"text":575},{"id":597,"depth":110,"text":598},"2023-02-20","VM provisioning, Kubernetes cluster setup, and application deployment — unified in a single Python codebase. A pattern for teams who want reproducibility without locking into a managed platform.",{},"/blog/full-infra-stack-pulumi-pyinfra-kubernetes",{"title":500,"description":622},"blog/full-infra-stack-pulumi-pyinfra-kubernetes",[628,496,629],"infrastructure","devops","NMURdqQo2_kjPHmN_LH7TgNzXAEGqUOfFbfgJpmhRM4",1771989751995]