Test-Driven Development (TDD) & Clean Architecture in Umbraco: Decoupling Domain Logic from CMS Dependencies
Umbraco makes it easy to ship fast. It also makes it easy to end up with business logic scattered across content models, controllers, and view templates, the kind of code nobody wants to touch six months later. The instant those rules become part of a document type or Razor view, you have created an integration between your business and your CMS that is difficult to test, and even more difficult to reverse.
While Clean Architecture and TDD have been around for some time, it just so happens that they provide a solution to a very Umbraco-specific problem: how do you keep your true business logic separate from a CMS that wasn’t really meant for it? In this post I’ll show you what that means in practice, and why testing-first changes everything.
Why Umbraco projects get hard to test
- Business logic often ends up inside controllers or view components because that's the fastest place to write it
- Domain rules get coupled to IPublishedContent or Umbraco's content models, so you can't test them without spinning up an entire CMS context
- Integration tests become the default because unit tests aren't practical when everything depends on Umbraco's runtime
- Every CMS upgrade risks breaking business logic that had no reason to be tied to Umbraco in the first place
- New developers can't tell where "the rules" actually live, because they're spread across five different layers
What Clean Architecture actually means in an Umbraco context
Clean Architecture isn't about adding more folders. It's about drawing a hard line between what your business does and how Umbraco happens to store and render it. A practical breakdown looks like:
- Domain layer: your core business rules and entities, with zero references to Umbraco, Umbraco.Cms.Core, or anything CMS-specific
- Application layer: use cases and orchestration, what actually happens when a user does something still with no CMS dependency
- Infrastructure layer: where Umbraco lives. Content services, published content queries, and anything touching IPublishedContent sit here
- Presentation layer: controllers, views, and razor views which make calls into the application layer and display the output
Dependency rule that allows for this: The outer layers may depend upon inner layers but not vice versa. Umbraco is the outer layer. Your domain logic doesn't know Umbraco exists.
Why TDD fits naturally into this
Unit tests, in turn, automatically require the separation of concerns that Clean Architecture demands.
- If the process of calculating discounts is embedded in a controller that also retrieves content from Umbraco, then it's impossible to create unit tests for its functionality until it's been extracted.
- And red-green-refactoring cycles work perfectly fine for business and application logic, as there is no CMS context to mock and instantiate.
- Tests written against interfaces (not concrete Umbraco services) stay stable even when Umbraco's APIs change between major versions
- Fast, in-memory test runs become normal again, instead of every test needing a database or a running Umbraco instance
- Bugs in business rules get caught before a single content editor or front-end template is involved
Decoupling domain logic from Umbraco dependencies
This is the part teams skip under deadline pressure, and it's the part that pays off later.
- Define interfaces in the domain or application layer for anything that needs data, a IProductRepository, not a direct call to Umbraco's content cache
- Let the infrastructure layer implement those interfaces using actual Umbraco APIs, content models, or Examine queries
- Map Umbraco content types into plain domain models as early as possible, so nothing downstream ever touches IPublishedContent directly
- Ensure validation and business logic are kept within domain objects or application services and not within the document type, view model or controller method
- Wire up infrastructure components using dependency injection such that the domain is only aware of the interface and not the concrete component implementation
- Treat any direct reference to Umbraco namespaces inside your domain project as a sign the boundary has already been crossed
A practical testing strategy for this setup
- Domain layer: pure unit tests, no mocking framework needed most of the time, because there's nothing external to fake
- Application layer: unit tests with mocked repository interfaces, verifying orchestration logic and use cases in isolation
- Infrastructure layer: integration tests that actually exercise Umbraco content queries, examine search, published content access
- Presentation layer: thinner tests here, mostly checking that controllers call the right application services and handle results correctly
- Run domain and application tests in CI on every commit; save the slower Umbraco integration tests for a separate pipeline stage
Best practices
- Start every feature by asking where the business rule belongs, not where it's easiest to write
- Resist the urge to inject IPublishedContent into anything outside the infrastructure layer, even when it feels faster
- Keep domain and application test suites fast enough that nobody skips running them locally
- Review pulls requests specifically for CMS leakage, a domain class importing an Umbraco namespace is a code smell, not a shortcut
- Revisit this separation during Umbraco major version upgrades; it's usually where the payoff becomes obvious
- Document the boundary once, clearly, so new team members don't reintroduce coupling out of habit
Where this leaves your codebase
None of this is about avoiding Umbraco or working around it. It's about giving your business logic a life of its own, one that isn't tied to how your CMS renders a page or stores a node. When domain rules live independently, they get easier to test, easier to reason about, and far easier to carry forward when Umbraco itself changes.
The upfront cost is real. Defining interfaces, mapping content models, and setting up separate test suites takes longer than writing everything inline. But teams that make this investment early spend far less time untangling business logic from CMS internals later — and that trade-off tends to look better with every passing sprint.
If your Umbraco application has outgrown the stage where adding something “just to the controller” is no longer sufficient, perhaps it is time to define that line. Contact Arroact on arroact.com/contact to discuss how this can be done in your case.
Related Blogs
Read More
Read More
Read More