How Modules Talk to Each Other
· 61 min read · Views --

How Modules Talk to Each Other

Author: Alex Xiang


The previous articles in Programmer Craft in the AI Era went from programs and data structures to execution models and concurrency. Now we move to a problem closer to daily development: how modules talk to each other.

The question looks ordinary. If one module needs another, do we not simply write a function, send an HTTP request, publish a message, or query a database? In real systems, however, the communication style determines coupling, transaction boundaries, failure propagation, latency, throughput, observability, and future room for evolution.

AI is good at filling in call code, but it often chooses the most convenient communication style by default. If you do not make the boundary explicit, it may turn something that should be asynchronous into a synchronous call, write an interface that should have a contract as an arbitrary dict, or bind modules that should be isolated through a shared database.

Communication Style Is System Boundary

Module communication is not simply “how to pass data over”. Every communication style answers several questions:

  • Does the caller need to wait for a result?
  • What does the caller do when the callee fails?
  • Must both sides be online at the same time?
  • Must the data take effect inside one transaction?
  • Who is affected when the interface changes?
  • Can the call chain be observed and traced?
  • When traffic suddenly increases, which side takes the pressure?

Common communication styles can first be placed in one table:

Communication styleTypical scenarioStrengthsMain risks
Function callSame-process modules, domain services, utility functionsSimple, fast, easy to type and testModule boundaries are easy to pierce; implicit dependencies grow
HTTP / RESTCross-service business APIs, public APIsGeneral, easy to debug, mature ecosystemLatency, timeouts, version compatibility, error-code design
RPC / gRPCInternal high-frequency service calls, strong schema scenariosClear contract, good performance, convenient code generationHigher debugging cost, stronger service-governance requirements
Message queueAsync tasks, peak absorption, decoupling, eventual consistencyCaller does not have to wait; traffic can be bufferedIdempotency, duplicate consumption, ordering, dead letters
Event streamAudit, data sync, multiple consumersTraceable, easy to add consumersSchema evolution, replay, offset management
Shared databaseSmall systems, legacy systems, reporting readsFast to implement, no extra interface layerStrong coupling, bypassed business rules, difficult decomposition
File / object storageLarge-file exchange, batch import/exportGood for large objects, low costLifecycle, permissions, cleanup, atomicity

This table is not for memorization. It is a reminder: before asking AI to write “call another module”, choose the communication style. If that choice is wrong, more code only makes the system harder to repair.

Function Calls: The Simplest Way to Lose a Boundary

Inside one process, function calls are the most natural communication style. They are fast, intuitive, easy to test, and the easiest thing for AI to write.

For example, inside an order service, AI may generate code like this:

def create_order(user_id: str, items: list[OrderItem]) -> Order:
    user = user_service.get_user(user_id)
    price = pricing_service.calculate(items, user.level)
    inventory_service.reserve(items)
    order = order_repository.create(user_id=user_id, items=items, price=price)
    notification_service.send_order_created(user.email, order.id)
    return order

At first glance, this is clear. But it mixes at least three kinds of operations:

  • Validation and calculation that must happen in the main flow.
  • Inventory reservation that must remain consistent with order creation.
  • Notification, which can be executed asynchronously.

The problem is not function calls themselves. The problem is that boundaries disappear too easily. All modules are in the same process, call cost is low, and AI naturally chains everything together. Eventually one business action becomes a long chain, and any slow or failing step holds the whole request.

A steadier split first separates the synchronous core path from asynchronous side effects:

def create_order(command: CreateOrderCommand) -> Order:
    user = user_policy.require_active_user(command.user_id)
    price = pricing.calculate(command.items, user.level)

    with transaction():
        inventory.reserve(command.items)
        order = order_repository.create(command.user_id, command.items, price)
        outbox.add("order.created", {"order_id": order.id})

    return order

Notification is not sent directly here. It is handled asynchronously through an outbox or message queue. Function calls still exist, but they serve only business rules inside the main transaction.

When asking AI to write same-process module calls, be explicit:

Separate the synchronous core path from asynchronous side effects.
The main flow includes only: validation, price calculation, inventory reservation, order creation.
Notifications, analytics, and recommendation updates go to the outbox and must not be called directly inside the request.
Mark whether each function is allowed to access the database and whether it is allowed to perform external I/O.

This is much stronger than “refactor this for me”.

HTTP and RPC: Interfaces Are Not Remote Functions

When a monolith is split into services, many systems directly turn function calls into HTTP calls. Function names become URLs, parameters become JSON, and return values become responses.

That usually leaves hidden risks.

When a local function call fails, throwing an exception is enough. When a remote call fails, the reason may be a timeout, connection failure, HTTP 500, gateway 502, request sent but response lost, or the callee successfully handled the request while the caller never received the result. It is not a remote version of a function call. It is protocol interaction over an unreliable network.

A normal-looking HTTP call:

def charge(order_id: str, amount: int):
    response = requests.post(
        "https://payment.example.com/charge",
        json={"order_id": order_id, "amount": amount},
    )
    response.raise_for_status()
    return response.json()

If AI stops here, it is still at demo level. A real system must ask:

  • What is the timeout?
  • Can failures be retried?
  • Will retrying charge the user twice?
  • Is there an idempotency key?
  • How are error codes classified?
  • Can the caller tolerate an unknown state?
  • How will the callee upgrade its API compatibly?
  • Are request and response tied to a trace ID?

Payment-like APIs must have idempotency keys:

def charge(order_id: str, amount: int):
    idempotency_key = f"charge:{order_id}"
    response = http_client.post(
        "/charge",
        json={"order_id": order_id, "amount": amount},
        headers={"Idempotency-Key": idempotency_key},
        timeout=3,
    )
    return parse_payment_response(response)

The key is not the code style. It is the protocol semantics. Both caller and service must accept that repeated requests with the same Idempotency-Key produce only one charge effect.

RPC and gRPC are the same. They provide stronger schemas and fit internal high-frequency calls well, but they do not remove timeouts, retries, idempotency, rate limits, circuit breakers, or version compatibility.

When asking AI to design a remote interface, ask for the contract:

Design the contract for this remote call:
1. Request fields, response fields, required fields, and optional fields.
2. Error-code classification: retryable, non-retryable, unknown state.
3. Timeout settings and retry strategy.
4. Idempotency-key design.
5. Version compatibility strategy, including added and deprecated fields.
6. Log and trace fields.
7. How the caller checks final status after timeout.

If AI only gives a URL and JSON fields, it is not enough. The important part of a remote interface is failure semantics.

Message Queues: Decoupling Is Not Throwing Work Away

Message queues are often used for “async”. After a user places an order, send an SMS. After a file is uploaded, parse it. After a report is created, generate it in the background. The caller publishes a message and returns.

That sounds nice, but a message queue is not a trash can. It only turns a synchronous problem into an asynchronous one.

A minimal message may look like this:

{
  "event_id": "6f01d3b8-5d1d-4df2-a4ce-9bb21a8b22b2",
  "event_type": "order.created",
  "occurred_at": "2026-07-03T10:00:00+08:00",
  "payload": {
    "order_id": "order_123",
    "user_id": "user_456"
  }
}

The most important field is not payload; it is event_id. Consumers use it for idempotency.

Common message-queue problems include:

  • Messages may be delivered more than once.
  • The consumer may process successfully but fail to commit offset or ack.
  • A failed message may retry forever.
  • Ordering may hold only for a certain key or partition.
  • When the queue backs up, latency grows.
  • After a consumer version upgrade, old message schemas may be incompatible.
  • No one watches the dead-letter queue, so failures become silent backlog.

AI often writes consumers like this:

def consume(message):
    data = json.loads(message.body)
    send_email(data["email"], data["template"])
    message.ack()

This lacks idempotency and failure classification. A more serious consumer first creates a processing record:

def consume(message):
    event = parse_event(message.body)

    if processed_events.exists(event.event_id):
        message.ack()
        return

    try:
        send_email(event.payload["email"], event.payload["template"])
        processed_events.mark_done(event.event_id)
        message.ack()
    except TemporaryError:
        message.retry_later()
    except PermanentError as exc:
        dead_letters.add(event, reason=str(exc))
        message.ack()

Real implementations must also consider transaction boundaries. Sending an email and writing a processing record do not naturally live in one transaction, so the external action should also be idempotent, or the outbox/inbox pattern should persist state.

When asking AI to write message-queue logic, do not merely say “retry on failure”. Say:

Consumers must be idempotent by event_id.
Messages for the same order_id must be processed in order.
Temporary errors retry at most 5 times with exponential backoff.
Permanent errors go to the dead-letter queue with enough information for manual inspection.
Emit metrics for success, duplicate skip, retry, and dead-letter.

These constraints directly change the code structure.

Event Streams: Not Every New Consumer Should Change the Caller

Message queues often focus on task processing. Event streams are closer to publishing facts. Order created, payment completed, user profile updated, inventory changed: all of these can be published as events and consumed by multiple downstream consumers.

The benefit of event streams is extensibility. Today there may be one analytics consumer. Tomorrow we can add recommendation, risk-control, or audit consumers without necessarily changing the order service.

But event streams require stronger schema discipline.

Events should describe facts that have already happened, not commands. order.created is a fact. send_email is a command. A fact can be understood by many consumers; a command usually serves one consumer.

An event schema can be designed like this:

{
  "event_id": "uuid",
  "event_type": "order.created",
  "schema_version": 2,
  "aggregate_type": "order",
  "aggregate_id": "order_123",
  "occurred_at": "2026-07-03T10:00:00+08:00",
  "producer": "order-service",
  "payload": {
    "user_id": "user_456",
    "total_amount": 12900,
    "currency": "CNY"
  }
}

Several fields are hard requirements:

  • schema_version handles format evolution.
  • aggregate_id supports ordering and aggregation for one object.
  • producer helps trace the source.
  • occurred_at is business occurrence time, not necessarily message write time.

AI can easily implement an event stream as “dump the current object to JSON”. That leaks internal fields and binds consumers to the producer’s database structure. When the producer changes a table, all consumers break.

An event payload should be an externally stable fact model, not an internal ORM object.

Shared Databases: Fastest in the Short Term, Stickiest in the Long Term

Shared databases are one of the most common early “communication styles”. Module A writes a table, module B reads it directly. If B needs to change state, it directly updates it.

It is fast, cheap, and needs no interface, queue, or service governance. In small systems it is completely acceptable.

The problem is that it bypasses business boundaries.

Suppose the order module has this table:

orders(id, user_id, status, total_amount, created_at, paid_at)

It is usually fine for analytics to read it. It is dangerous for customer service to directly modify status. Order state-transition rules may live in the order module code: unpaid orders cannot be shipped, cancelled orders cannot be paid, and refunding orders cannot be refunded twice. If an external module updates the table directly, it bypasses those rules.

Where does a shared database fit?

  • Reports and read-only analytics.
  • Internal modules in a small system.
  • A monolith that has not been split into services.
  • Read-only views or aggregate tables exposed to other modules.

Where does it not fit?

  • External modules directly modifying core business tables.
  • Multiple services owning write permission to the same table.
  • Treating database table structure as a public API.
  • Maintaining business rules with triggers and implicit updates.

If a database must be shared, reduce the damage:

create view order_report_view as
select id, user_id, status, total_amount, created_at, paid_at
from orders;

Expose only a view or read-only account, so other modules do not depend on the full table structure. A further step is building a dedicated read model or sync table, separating the business write model from the analytics read model.

When asking AI to work with a shared database, be explicit:

Other modules may only read order_report_view and must not write the orders table directly.
Order status changes must go through order_service.change_status().
Check whether the code contains cross-module direct updates to core tables.

This is more useful than saying “keep coupling low”.

Choose Communication by Drawing Failure Paths First

Many design diagrams only draw the success path:

User request -> Order service -> Payment service -> Notification service -> Done

The success path must be drawn, but the failure path is what really decides the communication style.

If an order is created and then needs payment and notification:

  • If the payment service times out, what state should the order be in?
  • If the payment request was sent but the network disconnected, can we confirm whether the user was charged?
  • Should notification failure affect order creation?
  • Is duplicate notification acceptable?
  • What if the payment-success event is lost?
  • After the order service restarts, how are unfinished flows recovered?

If notification failure must not affect the order, do not block the main chain with synchronous notification. It fits outbox + queue. If payment timeout leaves an unknown state, you need a payment query API and compensation job. If payment requests can repeat, idempotency keys are mandatory.

A more realistic flow might be:

HTTP request
  -> order service creates pending order
  -> call payment service with idempotency key
  -> after payment succeeds, order becomes paid
  -> write outbox: order.paid
  -> notification consumer sends message asynchronously
  -> analytics consumer updates report asynchronously

The failure path should also be drawn:

Payment timeout
  -> mark order as payment_unknown
  -> background job queries final payment status by order_id
  -> if success, transition to paid
  -> if failure, transition to payment_failed
  -> if unknown for too long, enter manual review queue

That is the engineering logic behind communication-style selection.

Version Compatibility Is Basic Courtesy Between Modules

Once modules communicate through interfaces, messages, events, or database views, versioning appears.

AI often assumes both sides upgrade at the same time. In production systems, this is usually false. Services may roll out gradually, consumers may lag, mobile clients may not upgrade for weeks, and queues may still contain old messages.

Several basic principles are practical:

  • New fields should usually be optional.
  • Do not casually change field meanings.
  • Do not remove fields still used by consumers.
  • When enum values are added, old consumers must handle unknown values.
  • Message schemas need versions.
  • Reading old versions and writing new versions requires a migration window.
  • API response fields should not expose internal table structures.

For example, adding an order status payment_unknown. If an old client only knows pending/paid/cancelled, the frontend may crash. A better API also returns a stable display state:

{
  "status": "payment_unknown",
  "display_status": "processing",
  "can_cancel": false
}

Then an old client can at least show “processing” instead of treating the unknown enum as an error.

When asking AI to change an interface, add:

Consider rolling release compatibility: old and new services will coexist, queues may contain old messages, and old clients may not recognize new enum values.

That sentence blocks many reckless changes.

Observability Must Follow the Communication Style

Once module communication grows, troubleshooting is no longer about reading one log file.

Synchronous calls need trace IDs so we can see which services a request crossed, how long each hop took, and where it failed. Message queues need event IDs, consumption latency, retry counts, and dead-letter counts. Event streams need offsets, lag, and schema versions. Shared databases need slow queries, lock waits, and cross-module write sources.

Different styles require different observation points:

Communication styleKey observations
HTTP / RPCRequest count, latency, error rate, timeout rate, retry count, trace ID
Message queueProduce rate, consume rate, backlog, consume latency, retry, dead letter
Event streamOffset, consumer lag, schema version, replay progress
Shared databaseSlow query, lock wait, connection count, caller source, table-level writes
File exchangeFile size, upload/download time, validation result, cleanup state

If AI-generated code only has business logic and none of these observation points, a production incident becomes guesswork. When asking AI to add observability, do not vaguely say “add logs”. List the fields:

Every remote-call log must include:
- trace_id
- caller
- callee
- operation
- request_id or event_id
- timeout_ms
- elapsed_ms
- result: success/retryable_error/permanent_error/timeout

Structured logs are far more useful than “call failed”.

A Module-communication Prompt for AI

When a feature involves multiple modules, first ask AI to design communication, not code:

Do not write code yet.

Design module communication for this feature:
1. List participating modules and the data each module owns.
2. Separate the synchronous core path, asynchronous side effects, read-only queries, and batch tasks.
3. Choose a communication style for each interaction: function call, HTTP/RPC, message queue,
   event stream, shared database, or file exchange.
4. Explain whether each interaction waits for a result, whether it is in one transaction,
   and how failures are handled.
5. Provide interface or message schema, including version field, idempotency key, and trace fields.
6. Analyze timeout, retry, duplicate call, out-of-order message, message loss, and lagging consumers.
7. List data tables or internal fields that must not be accessed across modules.
8. Provide metrics and log fields.
9. Only then write the code implementation plan.

If AI’s answer does not include failure paths, idempotency, version compatibility, and observability fields, it has not really designed module communication. It is only generating call code.

Ask These Questions Before Writing Code

Good communication choices keep a system clear. Bad ones make every later feature carry historical baggage.

Before writing code, ask:

  • Does this interaction really need to wait synchronously for a result?
  • Will failure affect the main flow?
  • Can retry cause duplicate side effects?
  • Is there an idempotency key?
  • Is the interface or message schema stable?
  • Can both sides be released independently?
  • What happens if a consumer lags or goes offline?
  • Is a table being written by multiple modules?
  • When something goes wrong, can the full path be found by trace ID or event ID?

These questions are not fashionable, but they are real engineering boundaries.

AI can quickly write HTTP clients, queue consumers, RPC handlers, database queries, and event publishers. The programmer’s job is to decide how these modules should speak, when they should stay silent, when they should wait, and when they should write something down and let the background path handle it slowly.

Code changes. Boundaries remain.