Sitemap
Raymond Meester

Raymond Meester

Blogs on Tech & Society.

Message-Oriented Programming

A Different Way to Think About Code

11 min readAug 23, 2026

--

You already know object-oriented programming: the object is the unit everything revolves around. You probably also know functional programming, where the function is the unit, and data-oriented programming, where it’s the data layout. There’s a fourth model that most software engineers use constantly without ever naming it: message-oriented programming. It’s the model behind Kafka, Camel, NiFi, Akka/Pekko, and dozens of integration and automation tools — but unlike the others, it was never written down as a language. It lives scattered across frameworks, APIs, and drag-and-drop canvases.

Press enter or click to view image in full size

That’s a shame, because once you see the pattern, it explains a lot: why integration code looks so different from application code, why “flow” means ten different things depending on who’s saying it, and why switching from Java to a tool like Camel or NiFi feels less like learning new syntax and more like learning to think differently. This post pulls that pattern out into the open — what a message actually is, the different mental models built on top of it, and what a general-purpose language designed around it might look like.

This is a summary, for a more in depth discussion, click here

What is a message, really?

At first a message looks trivial — some data with a bit of metadata attached. But define it carefully and it turns out to be a genuinely different kind of thing than an object or a variable.

A message is a data container — the only container of data in this model. Any string or integer you’re working with lives inside a message; there’s no notion of state existing outside of one. It’s also data in motion, not data at rest: a message isn’t a file or a database row, it’s transient, flowing from a producer through a series of processing steps to a consumer, existing only as long as that journey takes. Structurally, it’s simply a small tree: headers (metadata, as key–value pairs) and a body (the payload itself, which you can think of as just a special header). And because it has to cross process and network boundaries, a message is serializable by construction — JSON, Avro, Protobuf, whatever — interpretable by a consumer in a completely different language without ever sharing memory with the producer.

The one property that trips people up is that a message is mutable. The shipping-container analogy that gets used to explain messaging breaks down exactly here: in real integration work, what System A produces is rarely what System B needs, so the message gets restructured, enriched, and filtered as it travels. Some systems keep the original payload immutable for audit purposes, but headers almost always accumulate as the message moves. Mutation isn’t a bug in this model — it’s the point.

That gives messages some distinctive behavior. They’re self-contained: processing one shouldn’t require another message to have already been processed, or any shared conversational state between them (a message can still reference external state — customerId = 123 — without being any less a message; it's just less self-contained than one carrying the full state inline). They create decoupling: a sender doesn't need to know who processes a message, where, in what language, or when. And each one is isolated — it can pass through an entire flow, through multiple processing steps, without coordinating with any other message in flight. Note that this isolation is about processing, not transactions; a flow can still wrap a sequence of steps in a transaction, that's a property of the flow, not of the message.

The resulting programming model has a few consistent traits. It’s declarative: each step in a flow says what to do — validate, transform, route — not how. It’s flow-oriented: unlike a method or a function, a processing step doesn’t take multiple typed parameters and return one value; it just takes messages in and puts messages out, which is exactly what lets a flow be drawn as a graph of boxes. And it’s data-centric: despite the word “integration” pulling attention toward the systems being connected, what actually matters is the data in the message. It’s not really “an ERP integration,” it’s “an order integration” — the systems are just endpoints.

Put together, this is the mental model: not the object, not the function, but the message moving through a graph of processing steps.

The same shape, four different philosophies

Once you accept the message as the fundamental unit, the next question is: how do messages move, and what does moving them well actually mean? Four paradigms answer that question in genuinely different ways, even though they all draw the same picture of boxes and arrows.

Enterprise Integration Patterns (the model behind Apache Camel) treat integration as a vocabulary problem. Instead of writing bespoke glue code, you compose a route out of named, reusable patterns — Content-Based Router, Splitter, Aggregator, Wire Tap — each solving one recurring messaging problem. The pattern is the unit of thought; a route isn’t “code that reads a queue,” it’s “a router feeding two filters into an aggregator.”

Flow-Based Programming (the model behind Apache NiFi) doesn’t name what a processing step does semantically the way EIP does — a step can do anything. What it standardizes instead is the topology: independently-executing nodes connected by typed, bounded, back-pressured queues. NiFi cares less about “which named pattern is this” and much more about “what is the shape of the graph, and what’s happening to the data as it queues, prioritizes, and backs up across it.”

Event streaming (Kafka, Flink) throws out both “message routed through named patterns” and “message pushed through a process graph.” The primary abstraction becomes the stream itself — an unbounded, ordered, replayable log. Producers and consumers are decoupled not just in space but in time: a consumer can replay from any offset, and many independent consumers can read the same log at their own pace. Processing becomes a continuous query over an infinite dataset, which is why time — event-time, watermarks, windows — becomes a first-class citizen of the vocabulary in a way it never is in Camel or NiFi.

The Actor model (Pekko, the Apache-licensed fork of Akka) is the odd one out, because it isn’t really answering “how does data move through a topology” at all. It’s answering “what is the fundamental unit of safe, concurrent, stateful computation.” An actor holds private state no one else can touch, and can only send messages, create new actors, or change how it responds to the next message — nothing is shared, nothing blocks. It’s as much a concurrency and fault-tolerance model as an integration one, and in practice it often sits underneath the other three paradigms rather than competing with them.

None of these is “better” — they’re answers to different questions. EIP gives you a shared vocabulary when the pattern being applied is what matters to communicate. FBP gives you an inspectable, back-pressure-aware pipeline when the path of each unit of data matters. Streaming gives you a durable, temporally-aware substrate when the shape of the whole stream over time is the point, not any single message’s journey.

Who talks to whom: exchange patterns

Separately from how a message is processed, there’s the question of who is involved in the exchange — does the sender expect a reply, is there one receiver or many? Four patterns cover almost every case: one-way, where the sender’s job ends at delivery (the basis for commands and async jobs); request–reply, where a correlated response is expected (the messaging equivalent of a function call, and not inherently synchronous); publish–subscribe, where a message goes to a channel rather than an address, and any number of independent subscribers can pick it up; and scatter–gather, where a request fans out to several participants and their responses get collected into one combined result.

Get Raymond Meester’s stories in your inbox

Join Medium for free to get updates from this writer.

These aren’t competing paradigms — they’re primitives that show up inside any of the four paradigms above. A Recipient List in Camel can implement scatter-gather. A Kafka topic naturally gives you publish-subscribe. An actor can use one-way or request-reply semantics. The paradigm tells you how to think about messages; the exchange pattern tells you how participants talk to each other; underneath both sits the actual flow — how the message gets processed and moved.

Flow governs movement, service governs meaning

There’s one more distinction worth being precise about, because “flow” and “service” get used almost interchangeably in casual conversation and they shouldn’t be. A flow defines how a message is processed — the path, the steps, the routing. A service defines what capability is available — an independently usable piece of functionality behind a contract, indifferent to who calls it or how often. A flow can invoke a service mid-route, and a service can be implemented as a flow underneath, without either concept swallowing the other. Flows move; services sit.

That contract, in a service’s case, is worth pausing on, because the industry has converged on the same discipline over and over under different names: define the interface — in WSDL, OpenAPI, a .proto file, an AsyncAPI document, an EDI standard — before the implementation exists, and generate code from the contract rather than the other way around. SOAP did it first and strictest; OpenAPI brought the same discipline to REST; gRPC ties it directly to the wire format; GraphQL flips it around by letting the consumer choose the shape of the response while the schema still governs what's possible; and B2B/EDI standards were doing "contract-first" decades before the term existed, just with less friendly tooling. Different eras, different formats, same underlying idea — nothing gets wired up until the contract is agreed on.

Toward a language

Here’s the interesting move: if the message is a genuine paradigm and not just a family resemblance between middleware products, it should be possible to name its building blocks the way object-oriented programming named class, interface, enum, and record. Here's a proposed set of ten constructs, all of them describing how messages get produced, represented, transported, transformed, and processed:

  1. Task — a goal to be achieved, with a state machine, retries, and lifecycle — the thing that orchestrates flows and services toward an outcome.
  2. State — the persistent or externally managed resources a program depends on: connections, credentials, schemas, transaction stores. It has no lifecycle of its own; it’s declared once and referenced wherever it’s needed.
  3. Flow — the processing path a message takes through a system.
  4. Service — an independently usable capability behind a contract.
  5. Endpoint — the addressable interface a flow binds to on a specific system, usually a URI.
  6. Channel — the logical path a message travels between endpoints (a queue, a topic, a stream), independent of the specific broker technology.
  7. Format — the declared shape of serialized data: JSON, CSV, Avro, and so on.
  8. Mapper — a declarative, format-agnostic correspondence between a source shape and a target shape.
  9. Processor — an operation on a message within a flow: transform, filter, enrich, validate.
  10. Function — a smaller unit than a processor: takes the message plus other parameters and returns a value that isn’t necessarily a message — closer to an expression you call from inside a processor than a step in its own right.

These ten aren’t a strict hierarchy so much as pairs, each capturing a genuine tension: Task vs. State is goal versus memory — a task without state is unrunnable, but state without a task is just idle configuration. Flow vs. Service is how versus what — process abstraction against capability abstraction. Endpoint vs. Channel is who versus over-what-medium — an endpoint belongs to a sender or receiver, a channel belongs to the path between them. Format vs. Mapper is noun versus verb — a format declares a shape, a mapper is the declarative correspondence between two of them, which is why real mapping work tends to live in specialized tools rather than a generic transform step. And Processor vs. Function is statement versus expression — a processor has the same input/output shape as the flow it sits in (message in, message(s) out), while a function can take extra parameters and return something that isn’t a message at all, which is what makes it something you call rather than a box you draw.

A message-oriented taxonomy

What a program built from these could look like

This isn’t a formal language specification — more an investigation into what the syntax might look like if these constructs were made concrete. Below is a single, complete example strung together end to end: infrastructure and credentials declared as state, a CSV-to-JSON mapping, external systems as endpoints, messaging semantics as channels, an inbound REST contract as a service, a few processors, all wired together into a flow, and finally a task that governs the whole thing as one business outcome with a state machine and transactional guarantees.

// STATE — infrastructure, connections, auth
state("ordersDb")
.connection()
.type(ConnectionType.JDBC)
.url("jdbc:postgresql://db.internal:5432/orders_db")
.credentials("task_user", "{{secret:db.password}}")

state("paymentAuth")
.authentication()
.type(AuthType.OAUTH2_CLIENT_CREDENTIALS)
.tokenEndpoint("https://auth.payments.com/oauth/token")
.autoRefresh(true)

state("ordersTxStore")
.transaction()
.transactionManager(TxManager.XA_TWO_PHASE)
.connectionRef("ordersDb")
.deadLetterStore().enabled(true).retentionDays(14)

// DATA FORMAT + MAPPER — declared shapes and the correspondence between them
dataFormat("partnerBatchCsvFormat")
.csv().header("orderId", "customerId", "sku", "quantity", "unitPrice")

dataFormat("canonicalOrderJsonFormat")
.json().unmarshalType(CanonicalOrderDto.class)

mapper("csvToCanonicalJsonMapper")
.from("dataFormat:partnerBatchCsvFormat")
.to("dataFormat:canonicalOrderJsonFormat")
.transform()
.set("id", map.source("$.orderId"))
.set("pricing.totalPrice",
map.source("$.unitPrice").asDouble()
.multiply(map.source("$.quantity").asInteger()))

// ENDPOINTS + CHANNELS — external systems and messaging semantics
endpoint("stripePaymentGateway")
.http.uri("https://api.stripe.com/v1/charges").authRef("paymentAuth")

channel("highPriorityQueue")
.queue()
.durable(true)
.ordering(Ordering.FIFO)
.qualityOfService(QoS.EXACTLY_ONCE)

channel("orderAuditStream")
.stream()
.partitions(8)
.partitionKey("${header.orderId}")

// SERVICE — inbound contract
service("BatchOrderIngestionService")
.rest().path("/api/v1/batch-orders").consumes("text/csv")
.post().to("flow:csvOrderProcessingFlow")

// PROCESSORS — message manipulation and routing
processor("parseAndTransformCsv")
.unmarshal().format("partnerBatchCsvFormat")
.map("mapper:csvToCanonicalJsonMapper")
.marshal("dataFormat:canonicalOrderJsonFormat")

processor("routeByTier")
.choice()
.when(simple("${header.CustomerTier} == 'VIP'")).to("queue:highPriorityQueue")
.otherwise().to("stream:orderAuditStream")

// FLOW — the execution pipeline
flow("csvOrderProcessingFlow")
.steps()
.step("orderEndpoint").source().uri("http://server.com/orders")
.step("enrich").action().uri("processor:enrichCustomerTier")
.step("route").router().uri("processor:routeByTier")
.step("dispatch").sink()
.uri("endpoint:stripePaymentGateway").transactionRef("ordersTxStore")

// TASK — the top-level goal and lifecycle
task("OrderBatchIngestionGoalTask")
.goal("Ingest CSV order file, process payments, ensure zero loss via XA bounds")
.stateMachine()
.initial("INGESTING")
.state("PROCESSING_PAYMENT").to("COMPLETED")
.state("PROCESSING_PAYMENT").to("COMPENSATING")
.terminal("COMPLETED").terminal("COMPENSATED").terminal("FAILED")
.policies().timeoutMs(10000).transactional(true)
.orchestrate().flow("csvOrderProcessingFlow")
.start()

Read top to bottom, this is a full program in the message-oriented sense: state declares what the program depends on, format and mapper declare the shape of the data and how it’s translated, endpoint and channel declare connectivity, service declares the inbound contract, processor and flow declare what happens to a message and in what order, and task sits on top governing the whole thing as a single goal with a lifecycle. Nothing here is specific to one broker or one framework — it’s the same handful of constructs a Kafka Streams topology, a NiFi processor group, or a Camel route could all be expressed in.

Why this matters

Object-oriented programming didn’t become a real paradigm because someone drew a UML diagram — it became one because class, interface, and enum gave a huge and disparate set of tools a shared vocabulary to build on. Message-oriented programming already has the shared mental model — the message, moving through a graph, decoupled and self-contained — scattered across Kafka, Camel, NiFi, Pekko, and a dozen automation and workflow tools that all draw the same boxes and arrows for genuinely different reasons underneath.

What it’s missing is the shared taxonomy: a small, consistent set of type-kinds that can describe a route in Camel, a processor group in NiFi, a topology in Kafka Streams, or an actor system in Pekko using the same handful of terms — the way class, record, and interface let a Java developer read code from a colleague who's never touched their specific framework. If more middleware spoke the same language, solutions across this space would get more powerful and more interoperable, without each new tool having to reinvent the same ten ideas under ten different names.

--

--