Semi-Clean Architecture in Odoo — When Pure Theory Meets Real Products

I tried to apply Clean Architecture to an Odoo module. Here's what survived contact with reality.

The Temptation

Every developer who's read Uncle Bob's Clean Architecture has that moment. You look at your codebase — a tangle of models calling controllers calling wizards calling cron jobs — and you think: I could fix this. I just need layers.

I had that moment with an Odoo 18 project. A rental management system that started as "just a few models" and grew into a creature with business logic scattered across computed fields, write() overrides, controller endpoints, and cron methods. The kind of module where changing a pricing rule means touching seven files and praying.

So I grabbed the Clean Architecture book off my mental shelf and started drawing circles.

Domain layer. Use cases. Ports and adapters. Dependency inversion. The works.

It lasted about three hours before Odoo punched me in the face.

The Collision

Here's what Clean Architecture assumes:

  • You control your ORM
  • Your framework is a detail
  • Dependencies point inward
  • The domain knows nothing about persistence

Here's what Odoo assumes:

  • The ORM is the framework
  • self is simultaneously your model, your record, your query builder, and your business logic container
  • fields.Monetary doesn't just define a schema — it computes, validates, stores, and renders
  • Inheritance (_inherit) means your "pure domain" can be mutated by any installed module

You can't put Odoo in the outer circle. Odoo is the circles.

Trying to build a pure domain layer that doesn't know about models.Model is like trying to cook Cuban food without onions. Technically possible. But why would you do that to yourself?

What I Actually Did

Instead of fighting Odoo, I negotiated with it. The result isn't Clean Architecture. It's something I started calling Semi-Clean — a pragmatic subset that keeps the parts that actually help and drops the parts that create friction.

1. Separate Business Logic from CRUD Operations

The lowest-hanging fruit. Instead of this:

class RentalOrder(models.Model):
    _name = 'rental.order'

    def write(self, vals):
        if 'state' in vals and vals['state'] == 'confirmed':
            for rec in self:
                if rec.start_date < fields.Date.today():
                    raise ValidationError("Can't confirm past rentals")
                if not rec.vehicle_id.available:
                    raise ValidationError("Vehicle unavailable")
                rec.vehicle_id.available = False
                rec._compute_pricing()
                rec._send_confirmation_email()
                rec._create_invoice()
                rec._log_state_change()
        return super().write(vals)

Extract a service layer — still in Odoo, still using self.env, but at least the logic has a name and a home:

class RentalConfirmationService:
    """Handles the business rules for confirming a rental."""

    def __init__(self, env):
        self.env = env

    def confirm(self, order):
        self._validate(order)
        self._reserve_vehicle(order)
        self._compute_pricing(order)
        self._notify(order)
        self._invoice(order)

    def _validate(self, order):
        if order.start_date < fields.Date.today():
            raise ValidationError("Can't confirm past rentals")
        if not order.vehicle_id.available:
            raise ValidationError(
                f"Vehicle {order.vehicle_id.name} unavailable"
            )

    def _reserve_vehicle(self, order):
        order.vehicle_id.available = False

    # ... each step is a method, each method does one thing

The write() override becomes:

def write(self, vals):
    res = super().write(vals)
    if 'state' in vals and vals['state'] == 'confirmed':
        service = RentalConfirmationService(self.env)
        for rec in self:
            service.confirm(rec)
    return res

Is this "real" Clean Architecture? No. The service still knows about self.env, fields.Date, and Odoo models. But the business logic is in one place, testable in isolation, and the write() method is five lines instead of fifty.

2. Use Mixins as Interfaces (Sort Of)

Odoo doesn't have interfaces. But AbstractModel mixins get you halfway there:

class PricingStrategy(models.AbstractModel):
    _name = 'rental.pricing.strategy'
    _description = 'Abstract pricing strategy'

    def compute_price(self, order):
        raise NotImplementedError

class DailyPricing(models.AbstractModel):
    _name = 'rental.pricing.daily'
    _inherit = 'rental.pricing.strategy'

    def compute_price(self, order):
        days = (order.end_date - order.start_date).days
        return order.vehicle_id.daily_rate * days

class WeeklyPricing(models.AbstractModel):
    _name = 'rental.pricing.weekly'
    _inherit = 'rental.pricing.strategy'

    def compute_price(self, order):
        weeks = math.ceil(
            (order.end_date - order.start_date).days / 7
        )
        return order.vehicle_id.weekly_rate * weeks

Now your confirmation service doesn't hardcode pricing logic — it asks for a strategy:

def _compute_pricing(self, order):
    strategy = self.env[order.pricing_strategy_id.model_name]
    order.total_price = strategy.compute_price(order)

It's not dependency injection. It's not even close to what the book describes. But the pricing logic is swappable, extendable by other modules via _inherit, and doesn't live inside a 200-line write() method anymore.

3. Keep Your Domain Validations Together

The worst Odoo anti-pattern I've seen (and written) is validation logic scattered across @api.constrains, write(), create(), and random _check_* methods:

# Please don't do this
@api.constrains('start_date')
def _check_start_date(self):
    ...

@api.constrains('vehicle_id')
def _check_vehicle(self):
    ...

def write(self, vals):
    # more validation here for some reason
    ...

def action_confirm(self):
    # even more validation here
    ...

Instead, centralize it:

class RentalValidator:
    """All rental validation rules in one place."""

    @staticmethod
    def validate_for_confirmation(order):
        errors = []
        if order.start_date < fields.Date.today():
            errors.append("Start date cannot be in the past")
        if not order.vehicle_id:
            errors.append("No vehicle assigned")
        if not order.vehicle_id.available:
            errors.append(
                f"Vehicle {order.vehicle_id.name} is not available"
            )
        if order.end_date <= order.start_date:
            errors.append("End date must be after start date")
        if errors:
            raise ValidationError("\n".join(errors))

One class. One responsibility. When someone asks "what are the rules for confirming a rental?" — you point them to one file instead of playing treasure hunt across the module.

4. Don't Fight _inherit

This is where most "clean Odoo" attempts die. You build a beautiful, isolated module with clear boundaries. Then someone installs sale_subscription and suddenly your model has 15 new fields and three overridden methods you didn't ask for.

The pragmatic response: design for it.

  • Expect your write() to be extended. Keep it thin.
  • Expect new fields to appear. Don't hardcode field lists.
  • Put logic in services, not in model methods that others will override.
  • If another module breaks your service, that's a conversation — not an architecture failure.

What I Didn't Do

Just as important as what survived:

  • No separate domain models. I didn't create plain Python classes mirroring Odoo models. The duplication isn't worth it. rental.order is both my domain entity and my ORM model, and that's fine.
  • No repository pattern. Odoo's ORM is the repository. Writing self.env['rental.order'].search([]) inside a wrapper that calls self.env['rental.order'].search([]) is a waste of keystrokes.
  • No dependency injection container. self.env already is one. It resolves models by name, handles lifecycle, and supports overrides. It's not elegant, but it works.
  • No hexagonal ports. External integrations (payment gateways, email services) went through Odoo's standard mechanisms — mail.template, payment.provider. Fighting those just creates maintenance burden.

The Results

After restructuring one module along these lines:

  • Bug investigation time dropped. When a pricing error appears, I know it's in the pricing strategy, not somewhere in a write() override three inheritance levels deep.
  • Tests got simpler. Testing a service method with a mocked env is easier than testing a write() override that triggers computed fields, constrains, and signals.
  • New developers onboard faster. "Business logic is in services/, validation is in validators/, models are thin" — that's a sentence that saves hours of code archaeology.
  • Other modules can still extend. Nothing about this approach prevents _inherit. The services are just Python classes — anyone can subclass or replace them.

The Semi-Clean Checklist

If you're working on an Odoo module and want to move toward cleaner architecture without a full rewrite:

  1. Extract services. Any method longer than 20 lines with business logic → its own class.
  2. Centralize validation. One validator per model. All rules visible in one place.
  3. Keep models thin. Fields, computed fields, and one-liner delegations to services. That's it.
  4. Use mixins for strategies. Pricing, scoring, notification — anything with variants gets a mixin interface.
  5. Don't abstract the ORM. self.env is your friend. Accept it.
  6. Design for inheritance. Your module will be extended. Make that easy, not painful.

The Honest Truth

Semi-Clean Architecture isn't a pattern you'll find in a textbook. It's what happens when you take proven software design ideas and run them through the filter of "but I have to ship this on Odoo, and three other modules will inherit from it, and the client wants it by Friday."

It's not pure. It's not elegant. But it's better than a 500-line write() method and a prayer.

And honestly? For most Odoo projects, "better" is more than enough.