Article | October 5, 2026

One operation, three interfaces: a service architecture for django CMS

How shared content operations could support editors, REST integrations, and AI tools.

5 minutes read

Imagine asking an assistant to rearrange a campaign page for review. It should work within the same permissions and editorial rules as the person using the browser editor. That requires access to the operations the CMS understands: what can move, who may move it, and whether the content is currently editable.

A shared service layer would make those operations available to the editor, REST integrations, and AI tools through MCP (Model Context Protocol). For a company building an integration, the opportunity is concrete: implement the customer's workflow without also reconstructing django CMS's permission checks, content rules, and extension behaviour.

The central idea is simple: implement each business operation once, in a service that the admin, REST endpoints, and MCP servers all call. Each interface would handle its own inputs and outputs. The operation's business logic would have one shared implementation.

django CMS already has a browser editor, programmatic APIs, and support for content attached to application models. For django CMS 6, I propose a shared service contract resting on five pillars, described below, with REST and MCP access built on top of it. The technical design is discussed in django CMS issue #8868.

An example: moving a plugin

Rearranging a campaign page involves several decisions. One of its building blocks is moving an existing content plugin to an explicit destination, and this article uses that operation as its example.

Whether the move comes from the browser editor, a REST endpoint, or an MCP tool, acting for the same user on the same content, the outcome should be the same:

  • An allowed move produces the same ordering, cache updates, and operation notifications.
  • A move blocked by permissions or workflow state, such as a published version, is rejected with the same reason, leaving the content unchanged.
  • The REST description and the MCP tool definition come from the operation's own declaration, not from text written for each interface.

One implementation behind three interfaces

Moving a plugin involves more than updating its position. The operation has to check permissions and editability, validate the destination, invoke the model's tree operation, invalidate affected caches, and notify extensions.

If that sequence lives in an admin method, a REST endpoint and an MCP handler may end up reproducing it. A later fix then needs equivalent changes in three places. Even if all three call the same model method, the checks and side effects around it can diverge.

I propose putting that coordination in a service. The admin would delegate to it, just as the REST endpoint and MCP handler would. The services would be ordinary Python functions using Django models and the existing database.

                                     MCP tool adapter
                                   ┌─────────┴─────────┐
                          via REST │                   │ directly
                                   ▼                   │
  Browser editor /         REST API adapter            │         ...
     CMS admin                     │                   │
         │                         │                   │
         └─────────────────────────┼───────────────────┘
                                   ▼
                      ┌──────────────────────────┐
                      │ Shared content operation │
                      └────────────┬─────────────┘
                                   │
         ┌─────────────────────────┼─────────────────────────┐
         ▼                         ▼                         ▼
  Permission and           Django models and         Cache updates and
  workflow policy              querysets               notifications

This boundary could also make third-party contributions easier to integrate. A third-party developing an AI assistant could build a tool adapter against an agreed content operation. Core maintainers and extension authors would establish and validate the shared behaviour, including permissions, workflow rules, and compatibility. The adapter and the underlying operation could then serve further integrations.

That division still requires implementation and review in the core. An adapter depends on the shared operation's guarantees; agreeing and testing those guarantees would be part of the contribution.

After an adapter has established the actor and resolved the plugin and destination, a proposed call could look like this:

context = OperationContext(user=editor, language="en")

try:
    moved_plugin = move_plugin(
        plugin,
        target_placeholder=body_placeholder,
        target_parent=None,
        target_position=1,
        context=context,
    )
except PermissionDenied:
    ...  # not allowed for this user, or not in the content's current state
except ValidationError:
    ...  # the destination is not valid

The names illustrate the proposed interface. The service would enforce the operation's rules without requiring an admin instance or an HTTP request, and a refused move would raise before anything changes.

Each adapter would parse its input, establish the actor from authentication or an explicitly configured service identity, and translate the result into its own response format. The service would decide whether that actor can perform the requested move. The browser editor could still offer early feedback, but the operation would enforce the same rules regardless of its caller.

Where business logic belongs in Django

This proposal fits into a familiar Django discussion: how much behaviour belongs on a model, and when does it need another home?

Django's design philosophy encourages models to encapsulate their objects' domain logic. Its manager documentation distinguishes behaviour on an instance from operations at the table level. Both remain useful foundations.

I would divide the responsibilities this way:

Place Responsibility
Models and database constraints Object behaviour, persistence, and structural invariants
Managers and querysets Reusable queries and collection operations
Forms, serializers, and tool schemas Parsing inputs and validating their shape
Services Coordinating permissions, business validation, cleaning content data, model operations, transactions, and side effects
Admin, REST, and MCP adapters Establishing the caller's identity, building context, and mapping results to the services interface

A simple operation can stay on its model when that gives it a clear, reusable home. A service earns its place when coordinating a use case becomes a responsibility of its own. Models would retain meaningful methods, and services would use the Django ORM directly.

For django CMS, the content core should also remain usable across different kinds of content. Building on the support for editing application models in django CMS 4, placeholders and plugins should serve products, events, or any other model as well as pages. Page-specific behaviour would have a defined home without becoming an assumption in every content operation. That would let future REST and MCP integrations reuse the same foundation.

Five pillars

A shared function is a starting point. Its contract determines whether another interface can rely on it. I propose these five commitments as that contract and as the foundation for django CMS 6. Names and details of the interface may change; the commitments should not.

1. One operation, one implementation. Each content operation lives in one service. The editor, REST endpoints, and MCP tools are adapters: they establish who is calling, parse the input, and shape the response. They never decide whether the operation may happen. Parsing a plugin identifier belongs in the adapter; deciding whether the destination is valid belongs in the operation. Otherwise each new interface would have to reconstruct the rules.

2. Every operation is named and described once. An operation has a stable name, a one-line summary for people, and a plain description of what it changes, what it needs, and what may refuse it. The REST schema, the MCP tool definition, the editing history, and the documentation all take their wording from that single declaration. An integration does not write its own description of what moving a plugin means, so the descriptions cannot drift apart.

3. Every operation is someone's. An operation always runs on behalf of a known user, whose permissions it checks. Code acting on the system's own behalf, such as an import, names itself explicitly. Such a system identity is not subject to a user's permissions, but it is subject to the content's editorial state: by default, it cannot write into published content any more than an editor can.

4. Three different answers to "no". Before an operation changes anything, it asks three separate questions:

  • Permission: may this user do it?
  • Policy: does the content's current state allow it, whoever acts? It may be published, locked by another editor, or in review.
  • Validation: would the result be valid?

Each "no" calls for a different next step: ask someone with more rights, create a draft or wait for the lock, or choose another destination. Callers therefore receive them as different answers, so an assistant can tell its user "I couldn't move this, because the page is published" instead of reporting a bare error. A refusal never reveals anything about content the caller may not access.

5. Rules extend in one place, and operations complete as a unit. Extensions such as versioning, locking, or moderation add their rules to the operation itself, not to one interface, so the rules hold for the editor, REST, and MCP alike. An operation cleans its input the same way for every caller, so content sanitisation cannot be bypassed by calling it from somewhere else. It commits or fails as a whole, and it announces itself to extensions whether or not an HTTP request was involved.

What the pillars leave open

The pillars do not answer every question a remote caller raises. If a tool acts on an earlier view of a page, what should happen when another editor has changed it in the meantime? How should retries behave? These questions need deliberate answers before remote write access is offered, rather than answers that happen by accident.

Compatibility also needs testing rather than assuming. Existing import paths, model identity, and extension hooks matter to projects already using django CMS. Existing extensions may override admin permission hooks. An adapter can preserve those during migration, but equivalent rules have to apply to programmatic calls too. This is an area where extension authors' participation would be essential. Behavioural tests should exercise the shared operation and its adapters with the extensions projects rely on: one set of business rules, supported by focused tests for each interface's authentication, input handling, and responses.

Shaping the proposal

The concepts are open for discussion in issue #8868, and different perspectives would sharpen them. Integrators can say which operations and guarantees their workflows depend on. Extension maintainers can say whether their permission, versioning, and notification rules fit the three questions and the single place to register them. Core contributors can weigh how the services should relate to the existing programmatic APIs.

There is a maintenance cost to introducing another layer inside the CMS. Its value lies in business logic implemented once, consistent decisions through every entry point, and less CMS behaviour for an integration to reconstruct.

Which of these commitments would your integration depend on, and what is missing?

Article

One operation, three interfaces: a service architecture for django CMS

How shared content operations could support editors, REST integrations, and AI tools.

Release

django CMS 5.1.3 and 5.0.13 released

We’re pleased to announce the release of django CMS 5.1.3 and django CMS 5.0.13. Both are maintenance releases focused on fixes and improved robustness.

Community news

Inside the Work That Moves django CMS Forward

As the django CMS fellows, we have spent this year so far strengthening that foundation and making new capabilities available to developers and editors.