Odoo-modulariteit: razendsnel aanpassen, voorzichtig upgraden
Odoo is tegelijk een Belgisch softwarebedrijf en een suite van bedrijfsapplicaties. CRM, verkoop, boekhouding, voorraad, e-commerce en planning lijken voor de gebruiker losse apps, maar draaien technisch op hetzelfde platform en delen één geïntegreerde applicatie- en datalaag. De Community-editie is open source; daarbovenop biedt Odoo een Enterprise-editie en commerciële diensten aan.
Die brede functionele dekking is indrukwekkend, maar niet het interessantste aan Odoo. De echte superkracht is hoe gemakkelijk een module een bestaande applicatie kan uitbreiden. Een CRM-module kan velden toevoegen aan een contact, Verkoop kan knoppen op hetzelfde contactformulier plaatsen en een maatwerkmodule kan vervolgens het gedrag van beide aanpassen—zonder de broncode van die modules te kopiëren.
Dat levert een uitzonderlijk productief ecosysteem op. Het creëert tegelijk een technisch probleem: veel interne details worden impliciet uitbreidingspunten. Daardoor is maatwerk snel geschreven, maar kan het een volgende Odoo-upgrade langdurig tegenhouden.
In dit artikel bekijk ik hoe dat mechanisme in Odoo 19 werkt, hoe een letterlijke vertaling naar .NET eruit zou zien en hoe ik de modulariteit vandaag anders zou ontwerpen.
Hoe Odoo-modules vandaag werken
Een Odoo-module is veel meer dan een Python-package. Ze kan modellen, velden, views, toegangsregels, menu’s, acties, geplande taken, data en webassets bevatten. In __manifest__.py beschrijft de module onder andere haar afhankelijkheden en databestanden:
{
"name": "Service Management",
"version": "19.0.1.0.0",
"depends": ["contacts", "sale"],
"data": [
"security/ir.model.access.csv",
"views/res_partner_views.xml",
"views/service_job_views.xml",
],
"application": True,
}
Odoo laadt eerst de modules uit depends en registreert hun modeldefinities tijdens de opbouw van de registry. De bestanden uit data worden bij installatie of een expliciete module-update geïmporteerd, niet bij iedere normale serverstart. De officiële documentatie over module-manifests beschrijft deze metadata en laadvolgorde.
Een bestaand model openbreken met _inherit
De meest kenmerkende vorm van modulariteit zit in de ORM. Een module kan een bestaand model uitbreiden door _inherit te gebruiken zonder een nieuwe _name te declareren:
from odoo import fields, models
class ResPartner(models.Model):
_inherit = "res.partner"
service_job_count = fields.Integer(
compute="_compute_service_job_count"
)
def _compute_service_job_count(self):
for partner in self:
partner.service_job_count = self.env["service.job"].search_count(
[("partner_id", "=", partner.id)]
)
def action_archive(self):
result = super().action_archive()
self.env["service.job"].search(
[("partner_id", "in", self.ids)]
).action_cancel()
return result
Na installatie heeft res.partner er een veld en gedrag bij. Andere modules gebruiken nog steeds dezelfde modelnaam en dezelfde databaserecords. In de standaardbroncode doen bijvoorbeeld zowel CRM als Verkoop dit met res.partner: ze voegen elk relaties, berekende tellers en methodes toe.
Odoo ondersteunt daarnaast twee varianten:
_inheritin combinatie met een nieuwe_namemaakt een nieuw model op basis van een bestaand model._inheritsgebruikt delegatie: een model verwijst naar een ander record en stelt diens velden beschikbaar alsof ze van zichzelf zijn. Dit delegeert velden, geen methodes.
De verschillen staan in de Odoo ORM-documentatie over inheritance en extension.
Bij een override betekent super() niet simpelweg “roep de originele Odoo-methode aan”. Het betekent: ga naar de volgende implementatie in de samengestelde Python method resolution order (MRO). Als drie geïnstalleerde modules dezelfde methode uitbreiden, vormen ze samen een keten. Dat is krachtig: elke module kan vóór of na super() gedrag toevoegen. De keerzijde is dat één module die super() niet of verkeerd aanroept, de rest van de keten kan overslaan. De Odoo-broncode die dynamische modelklassen bouwt maakt die compositie expliciet.
Views samenstellen met XML en XPath
Ook een formulier wordt niet noodzakelijk door één module beheerd. Een module erft een bestaande ir.ui.view en beschrijft waar extra XML terecht moet komen:
<record id="res_partner_form_service" model="ir.ui.view">
<field name="name">res.partner.form.service</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button type="object"
name="action_view_service_jobs"
class="oe_stat_button"
icon="fa-wrench">
<field name="service_job_count" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
Odoo resolveert eerst de parent-view en past vervolgens de overervende views toe. Een extensie kan elementen toevoegen, vervangen, verplaatsen of attributen wijzigen. De regels voor matching, position, prioriteit en resolutie staan in de documentatie over view records.
Hetzelfde idee komt terug op andere plaatsen. XML-records kunnen bestaande acties en configuratie aanpassen, assetbundels kunnen bestanden toevoegen of verwijderen en frontendcode kan componenten of objecten in-place patchen. Modulariteit is in Odoo dus geen harde grens rond een onderdeel. Het is een mechanisme om tijdens het laden één grotere applicatie samen te stellen.
Waarom dit model zo aantrekkelijk is
Odoo optimaliseert voor veranderbaarheid door implementatiepartners en functionele consultants. Daar is deze architectuur bijzonder goed in.
- Een kleine module kan een verticale functionaliteit leveren: datamodel, beveiliging, UI en data zitten samen.
- Een uitbreiding hoeft de oorspronkelijke module niet te forken. Daardoor blijven lokale wijzigingen relatief klein.
- Het framework neemt veel infrastructuur over: ORM, transacties, toegangsregels, metadata, views en RPC.
- Modules kunnen op vrijwel ieder niveau inhaken. Voor ongebruikelijke klantvragen is er bijna altijd een uitweg.
- De aanpak schaalt functioneel. Een lege database kan met enkele apps uitgroeien tot een geïntegreerd ERP-pakket.
Die openheid is geen architecturale vergissing. Ze is een bewuste keuze die een groot deel van Odoo’s commerciële succes verklaart. Alleen wordt de rekening van die keuze vaak pas bij een upgrade zichtbaar.
De nadelen: de extensiegrens is te breed
De onderstaande tabel vat de centrale afweging samen.
| Mechanisme | Sterkte | Typisch upgraderisico |
|---|---|---|
_inherit en method overrides | Bestaand gedrag vrijwel onbeperkt uitbreiden | Nieuwe signatuur, gewijzigde semantiek of een andere super()-keten |
| Extra ORM-velden | Eén geïntegreerd record voor alle modules | Naamconflicten, gewijzigde velddefinities en schema- of datamigraties |
| Overervende XML-views | Een bestaand scherm aanpassen zonder fork | XPath of doel-element bestaat niet meer of heeft een andere betekenis |
| XML-data en security records | Configuratie mee versioneren | Gewijzigde externe ID’s, groepen, regels of impliciete defaults |
| JavaScript-patches | Ook de webclient diepgaand aanpassen | Interne componenten en lifecycle veranderen tussen versies |
1. Gedrag is niet lokaal leesbaar
Een ontwikkelaar die res.partner.action_archive() opent, ziet niet automatisch alle code die werkelijk zal draaien. Daarvoor zijn de geïnstalleerde modules, hun afhankelijkheden, de samengestelde modelklasse en alle overrides relevant.
De volgorde is niet willekeurig: Odoo bouwt via een deterministisch gesorteerde modulegraph een registry en Python past zijn MRO toe. Ze is wel niet lokaal voorspelbaar. Een extra module kan een nieuwe schakel aan de keten toevoegen. Een upgrade kan in de kern of in een officiële add-on een override toevoegen, verwijderen of semantisch wijzigen. De maatwerkcode compileert dan mogelijk nog—Python geeft hier sowieso weinig compile-time garanties—terwijl het resultaat functioneel veranderd is.
2. Implementatiedetails worden onbedoeld publieke interfaces
Een methode die nooit als stabiele interface ontworpen werd, kan toch door tientallen maatwerkmodules worden overschreven. Hetzelfde geldt voor de naam en positie van een <group> in een formulier. Zodra maatwerk ervan afhangt, is die interne structuur in de praktijk publiek geworden, maar zonder de versiegaranties en beperkte oppervlakte van een bewust ontworpen interface.
Dat plaatst ook Odoo zelf voor een dilemma: interne code verbeteren kan downstream-extensies breken; niets wijzigen laat technische schuld staan.
3. View inheritance is krachtig maar structureel gekoppeld
Een XPath zoals //sheet/group[2] zegt niet waarom een element daar hoort. Hij zegt alleen waar het vandaag staat. Zelfs een semantisch anker zoals //div[@name='button_box'] blijft afhankelijk van de structuur van de parent-view.
Bij een upgrade kan een target verdwijnen, meerdere keren matchen of naar een ander scherm verhuizen. Soms faalt de view tijdens het laden. Lastiger zijn wijzigingen die wel laden maar een knop op de verkeerde plaats tonen of beveiligingsattributen anders combineren.
4. Modules delen proces, registry en datamodel
Een Odoo-module is geen geïsoleerde component. Ze draait in hetzelfde proces, gebruikt dezelfde ORM-registry en kan modellen van een andere module aanpassen. Dat maakt integratie gemakkelijk, maar ownership vaag. Welke module bezit een veld dat door drie modules gebruikt en door een vierde berekend wordt? Wie mag de invarianten wijzigen? Het manifest declareert een afhankelijkheid op moduleniveau, niet het concrete contract waarop een override steunt.
5. Veel fouten verschijnen pas laat
Python, dynamische modelcompositie en XML leveren minder statische feedback dan sterk getypeerde contracten. Een ontbrekend veld, fout XPath, ongeldige externe ID, contextafhankelijke access rule of incompatibele override wordt vaak pas zichtbaar bij:
- installatie of upgrade van de module;
- het opbouwen van de registry;
- het openen van een specifieke view;
- een specifieke combinatie van gebruiker, bedrijf, taal en geïnstalleerde apps;
- of een businessflow die de overrideketen werkelijk doorloopt.
Daarom is “de server start” geen overtuigend bewijs dat een upgrade veilig is.
6. Maatwerk maakt van een release-upgrade een integratieproject
Dit is voor mij het grootste nadeel. Met upgrade bedoel ik hier de permanente overgang naar een nieuwe Odoo-release, bijvoorbeeld van 18 naar 19. Odoo onderscheidt dat zelf van een bugfix-update binnen dezelfde release, die de database niet rechtstreeks wijzigt en in beginsel omkeerbaar is. Het zou dus te sterk zijn om te stellen dat maatwerk iedere update blokkeert.
Voor een release-upgrade is de situatie scherper. Een standaarddatabase kan een voorgeschreven upgradepad volgen. Volgens de algemene upgradehandleiding kan een database met maatwerk pas upgraden wanneer voor iedere custom module een versie voor de doelrelease beschikbaar is. Odoo’s handleiding voor het upgraden van een aangepaste database noemt verplaatste of verwijderde XPaths, modellen, velden en methodes als typische breuken. Ze voorziet daarom expliciet in een code freeze, het aanpassen van custom code, upgrade-scripts, een testdatabase, uitgebreide tests en een rehearsal vóór productie.
De impact zit niet alleen in evidente breuken. Als engineeringpraktijk zou ik iedere override opnieuw tegen de nieuwe keten beoordelen:
- Is de methode of view nog aanwezig?
- Is het contract inhoudelijk hetzelfde gebleven?
- Roept
super()nog de implementaties aan die we verwachten? - Zijn nieuwe core-validaties of neveneffecten behouden?
- Werken alle relevante modulecombinaties en toegangsrechten nog?
Soms volstaat een kleine aanpassing. Soms moet een override volledig herschreven worden. Custom modules—en vooral overrides en structurele view inheritance—kunnen een release-upgrade daardoor vertragen of blokkeren totdat ze aangepast en getest zijn. Het probleem is dus niet dat overrides letterlijk random zijn, maar dat hun betekenis afhangt van globale runtimecompositie en veranderlijke implementatiedetails.
Wat als we Odoo letterlijk naar .NET vertalen?
In een vrij letterlijke vertaling wordt een addon een class library of NuGet-package, het manifest een module-descriptor, depends een project- of package-reference, een ORM-model een C#-type en een view een Razor-onderdeel. Services worden via dependency injection samengesteld. Een eerste poging om de overrides te vertalen zou waarschijnlijk gewone overerving gebruiken:
public class PartnerService
{
public virtual Task ArchiveAsync(Partner partner) => Task.CompletedTask;
}
public sealed class ServicePartnerService : PartnerService
{
public override async Task ArchiveAsync(Partner partner)
{
await base.ArchiveAsync(partner);
await CancelOpenServiceJobs(partner.Id);
}
}
Dat werkt zolang de host één lineaire inheritanceketen compile-time kent. Zodra CRM, Verkoop en Service onafhankelijk van elkaar PartnerService willen uitbreiden, worden de mogelijke combinaties en hun volgorde een expliciete verantwoordelijkheid. Odoo lost dat op door tijdens de registry-opbouw verschillende uitbreidingen in één modelklasse te combineren. Om dat letterlijk na te bouwen in .NET zouden we runtime-proxies, codegeneratie of een keten van decorators nodig hebben.
Dat kan, maar dan verplaatsen we het probleem. De registratievolgorde van decorators wordt de nieuwe MRO en brede interfaces worden opnieuw impliciete extensie-API’s. Voor views heeft Razor bovendien geen natuurlijke tegenhanger van Odoo’s XPath-compositie. We zouden dus veel dynamiek moeten bouwen om ook dezelfde zwakke plekken te reproduceren.
De betere vraag is niet: “Hoe bootsen we _inherit na in C#?” De betere vraag is: “Hoe behouden we Odoo’s uitbreidbaarheid met expliciete, controleerbare contracten?”
Een betere .NET-opzet: modules werken via interfaces, niet via internals
Ik zou starten met een modulaire monoliet. Modules blijven afzonderlijke assemblies en worden samen gedeployed, maar iedere module bezit haar domein, tabellen, migraties en publieke interface. De host bepaalt welke modules actief zijn.
Daarbij moet een module diep zijn: veel gedrag en complexiteit zitten achter een kleine interface. De plek waar ander gedrag ingeplugd kan worden, noem ik een seam. In Odoo kan vrijwel iedere methode of XML-node zo’n seam worden. In het .NET-ontwerp publiceert de eigenaar alleen seams die hij bewust stabiel kan houden. Dat geeft callers leverage en houdt kennis, wijzigingen en tests lokaal bij de eigenaar.
1. Een expliciete loader-interface
public sealed record ModuleDescriptor(
string Name,
Version Version,
IReadOnlyList<string> DependsOn,
IReadOnlyList<string> Capabilities);
public interface IModule
{
ModuleDescriptor Descriptor { get; }
void RegisterServices(IServiceCollection services);
void RegisterExtensions(IExtensionRegistry extensions);
void RegisterMigrations(IMigrationRegistry migrations);
}
Een moduleloader sorteert de dependency graph topologisch en valideert bij het opstarten minstens:
- ontbrekende en circulaire dependencies;
- dubbele modulenamen en extensie-ID’s;
- uitbreidingen voor onbekende extension points;
- incompatibele contexttypes;
- conflicterende migratieversies.
De ingebouwde .NET dependency injection verzorgt objectcompositie, maar een eigen kleine modulekernel bewaakt de regels. Dependency injection alleen is geen modulesysteem.
2. Kies de seam per bedoeling
Niet iedere uitbreiding is een override. Ik zou vier expliciete mechanismen aanbieden:
| Bedoeling | Seam |
|---|---|
| Reageren nadat iets gebeurd is | Typed domain- of integration event |
| Een beslissing valideren of verrijken | Named business-rule- of pipeline-slot |
| Een paneel of actie aan een scherm toevoegen | Typed UI-slot |
| Gegevens aan een concept koppelen | Module-owned tabel met een stabiele foreign key |
Een CRM-module die na het aanmaken van een contact iets wil doen, registreert bijvoorbeeld een handler:
public sealed record ContactCreated(Guid ContactId);
public interface IEventHandler<in TEvent>
{
Task HandleAsync(TEvent message, CancellationToken cancellationToken);
}
public sealed class PrepareCrmProfile : IEventHandler<ContactCreated>
{
public Task HandleAsync(
ContactCreated message,
CancellationToken cancellationToken)
{
// CRM schrijft uitsluitend naar tabellen die CRM zelf bezit.
return Task.CompletedTask;
}
}
De Contacts-module hoeft CRM niet te kennen. Het event maakt deel uit van haar interface; de handler is de adapter die CRM aan die seam plaatst. Microsoft beschrijft domain events precies als een manier om neveneffecten expliciet en los van de producer af te handelen.
Voor kritieke synchrone beslissingen, zoals “mag deze verkooporder bevestigd worden?”, is een benoemde policy-seam geschikter:
public interface IConfirmOrderPolicy
{
string Id { get; }
int Order { get; }
ValueTask CheckAsync(
ConfirmOrderContext context,
CancellationToken cancellationToken);
}
foreach (var policy in policies
.OrderBy(policy => policy.Order)
.ThenBy(policy => policy.Id, StringComparer.Ordinal))
{
await policy.CheckAsync(context, cancellationToken);
}
order.Confirm();
De eigenaar bepaalt hier expliciet de context, foutafhandeling en volgorde. De registry weigert dubbele ID’s of ongeldige ordening. Een policy kan de validatiefase uitbreiden, maar niet ongemerkt de volledige ConfirmAsync()-implementatie vervangen of vergeten de order werkelijk te bevestigen.
3. Typed UI-slots in plaats van XPath
De eigenaar van een scherm publiceert doelbewust plekken die uitbreidbaar zijn:
<ContactHeader Contact="Model.Contact" />
<main>
<ContactSummary Contact="Model.Contact" />
<UiSlot Name="contact.details.main" Context="Model" />
</main>
<aside>
<UiSlot Name="contact.details.sidebar" Context="Model" />
</aside>
CRM kan vervolgens een component registreren zonder de interne markup van het contactscherm te kennen:
public sealed class ContactOpportunitiesPanel : IUiExtension
{
public string Id => "crm.contact.opportunities";
public string Slot => "contact.details.sidebar";
public Type ContextType => typeof(ContactDetailsContext);
public int Order => 200;
public Type Component => typeof(OpportunitiesPanel);
}
De registry controleert bij startup of het slot bestaat en het contexttype klopt. Binnen Blazor kan een gedeelde slotmodule de geregistreerde Razor-onderdelen via DynamicComponent renderen. Een herstructurering van de private Razor-markup breekt CRM niet zolang de naam en interface van het slot stabiel blijven.
Dit beperkt uiteraard wat maatwerk kan doen. Dat is net de bedoeling: een module kan alleen uitbreiden waar de eigenaar een ondersteunde seam publiceert. Ontbreekt een legitiem scenario, dan voegen we bewust een nieuw uitbreidingspunt toe in plaats van via XPath of monkeypatching binnen te breken.
4. Data ownership in plaats van velden injecteren
Ik zou CRM niet toestaan om willekeurig properties aan de Contact-aggregate van Contacts toe te voegen. CRM bewaart bijvoorbeeld CrmContactProfile in een eigen tabel, geïndexeerd op ContactId. Een samengestelde read query mag beide gegevens voor de UI combineren, maar de schrijfregels en migraties blijven bij de eigenaar.
Dat kost soms een join en iets meer expliciete mapping. Daartegenover staan duidelijke invarianten, voorspelbare uninstall- en migratiepaden en minder schema-aanvaringen. Het is dezelfde afweging als elders: wat minder magie in ruil voor een veel kleinere impactradius.
5. Maak volgorde en compatibiliteit onderdeel van het contract
Wanneer meerdere extensies toch in een keten moeten draaien, mogen ze niet vertrouwen op toevallige discovery- of registratievolgorde. Iedere registratie krijgt een unieke ID en desnoods een fase, expliciete prioriteit of before/after-relatie. De loader resolveert die volgorde deterministisch en weigert cycli of dubbelzinnigheid.
Publieke module-interfaces krijgen een versie en een deprecationbeleid. Interfacetests controleren of aanbieders en callers nog overeenkomen. De C#-compiler vangt gewijzigde typen en signatures vroeg op; startupvalidatie vangt configuratiefouten op; integratietests blijven nodig voor semantiek en end-to-endgedrag. Sterke types schaffen upgraderisico dus niet af, maar verschuiven veel fouten naar een goedkoper moment.
De prijs van een striktere architectuur
Ook dit ontwerp heeft nadelen. Module-eigenaars moeten vooraf nadenken over bruikbare seams. Een klantvraag buiten die grenzen vraagt een wijziging aan de publieke interface. Sidecar-tabellen en samengestelde queries zijn omslachtiger dan één dynamisch ORM-model. Interfaceversies, events en registries brengen infrastructuur en governance mee.
Toch vind ik dat een gezonde prijs. Odoo kiest standaard voor maximale uitbreidbaarheid en betaalt met upgradecomplexiteit. Voor een nieuwe .NET-implementatie zou ik de balans verschuiven naar expliciete ownership en beperkte uitbreidingsoppervlakken:
Open voor bekende extensies, gesloten voor toevallige afhankelijkheden.
Het doel is niet om maatwerk onmogelijk te maken. Het doel is dat een custom module precies kan verklaren welke interfaces ze gebruikt, dat de host die afhankelijkheden vóór de eerste request valideert en dat een upgrade niet vereist dat we de volledige runtimecompositie opnieuw in ons hoofd reconstrueren.
Conclusie
Odoo’s modulariteit is tegelijk haar grootste sterkte en haar belangrijkste technische hypotheek. _inherit, super() en XML-view inheritance maken aanpassingen snel, klein en commercieel haalbaar. Maar ze veranderen methodes en UI-structuren die op papier intern zijn in een enorm, impliciet extensieoppervlak.
Daarom houden overrides upgrades tegen: niet omdat hun uitvoering willekeurig is, maar omdat hun correcte werking afhankelijk is van alle andere geïnstalleerde modules en van implementatiedetails die tussen Odoo-versies mogen veranderen. Veilig upgraden betekent dus analyseren, migreren en breed regressietesten—en geregeld herschrijven.
Moest ik Odoo vandaag in .NET bouwen, dan zou ik de functionele modulariteit behouden en de technische openheid begrenzen. Expliciete moduledependencies, typed events, benoemde business pipelines, typed UI-slots, data ownership en fail-fastvalidatie leveren minder magie op. Precies daardoor wordt het systeem beter leesbaar, testbaar en upgradebaar.
Verder lezen
- Odoo 19 — Module manifests
- Odoo 19 — ORM inheritance and extension
- Odoo 19 — View resolution en inheritance
- Odoo 19 — Een aangepaste database upgraden
- Odoo 19 — Bugfix-updates binnen een release
- Odoo 19-bron — Dynamische modelklassen en MRO
- .NET — Dependency injection
- .NET — Domain events
- ASP.NET Core Blazor — Dynamic components