Arhitektura sustava: Izgradnja baza podataka koje pamte kako se upotrebljavaju
A database is often treated as a passive container: the application writes facts, reads facts, and leaves the database to remember only the current state. That model works until a system needs to answer a more useful question: not just “what is true now?” but “how did this get used, changed, approved, retried, or reached this state?”
Systems that remember their own use are easier to debug, safer to evolve, and more honest about how work actually happens. The challenge is avoiding the opposite extreme: turning every table into an unbounded event dump that nobody can query, maintain, or afford.
Model state and behavior separately
A row such as orders should describe the present business state: customer, total, status, timestamps, and the fields required to operate the order. It should not be forced to carry every detail of its journey in increasingly awkward columns such as last_status_before_current_status or was_ever_flagged.
Instead, keep current state close to the domain record and record meaningful behavior in a related history or event table. This makes the distinction explicit:
- Current state answers what the system believes now.
- History answers what happened and when.
- Derived views answer operational questions efficiently, without making every request reconstruct the past.
For example, an order can remain straightforward while a separate table records state transitions:
CREATE TABLE order_status_history (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
from_status VARCHAR(32),
to_status VARCHAR(32) NOT NULL,
changed_at TIMESTAMP NOT NULL,
changed_by_user_id BIGINT,
reason VARCHAR(255)
);
This is not event sourcing by default. It is a pragmatic audit trail for behavior that matters. The distinction matters because full event sourcing changes how state is written, rebuilt, versioned, and operated. A history table can provide most of the diagnostic and compliance value without requiring every part of the application to become an event processor.
Capture intent, not noise
The most common mistake is logging everything simply because it is possible. A request-level log entry for every page view, cache lookup, and internal method call can be useful in a short-lived observability system, but it is rarely a good substitute for durable domain history.
Persist events when they explain a business decision, a user-visible change, a security-relevant action, or an irreversible side effect. Examples include:
- an invoice being issued, voided, or paid;
- a user’s permission being granted or revoked;
- a webhook being accepted, rejected, or retried permanently;
- a deployment changing an active configuration version;
- an import row being skipped because validation failed.
Each stored event should have a clear consumer. That consumer might be support staff investigating an issue, a scheduled reconciliation job, an audit screen, or a downstream integration. If nobody can name the question an event will answer, the data may belong in transient application logs instead.
Design for causality
A timestamp tells you when something happened. It does not always tell you why. The useful part of a history record is often the context around the transition: the actor, request, source, reason, correlation identifier, and previous value where appropriate.
Consider a payment attempt. The payment record needs its current result, but the architecture should also make it possible to distinguish a customer retry from a provider timeout, a background retry, or a duplicate callback. A small amount of carefully chosen context prevents hours of inference during an incident.
$history->record([
'payment_id' => $payment->id,
'event_type' => 'payment.failed',
'occurred_at' => $clock->now(),
'actor_type' => 'system',
'correlation_id' => $requestContext->correlationId(),
'reason_code' => $gatewayResult->failureCode(),
]);
Do not store raw request bodies, access tokens, passwords, or payment details merely for convenience. History is durable by design, which makes careless capture a long-term security and privacy problem. Prefer stable identifiers, safe reason codes, and deliberately redacted metadata.
Keep writes reliable with transactions and an outbox
When an action changes state and creates a history entry, those operations usually belong in the same database transaction. An order marked as shipped without a matching history record is misleading; a history record claiming shipment when the order update rolled back is worse.
The situation becomes more subtle when the action also publishes a message, sends email, or calls another API. A process cannot safely assume that a database transaction and an external network call will succeed or fail together. Calling the external service before committing risks a message about data that never persisted. Calling it after committing risks losing the message if the process stops at the wrong moment.
The transactional outbox pattern handles this boundary. Write the domain change, its history, and an outbox row in one transaction. A separate worker reads pending outbox records and delivers them with retries. Consumers must be idempotent because a worker can successfully deliver a message and fail before recording that success.
BEGIN;
UPDATE orders
SET status = 'shipped'
WHERE id = :order_id;
INSERT INTO order_status_history
(id, order_id, from_status, to_status, changed_at)
VALUES
(:history_id, :order_id, 'paid', 'shipped', CURRENT_TIMESTAMP);
INSERT INTO outbox_messages
(id, topic, payload, created_at)
VALUES
(:message_id, 'order.shipped', :payload, CURRENT_TIMESTAMP);
COMMIT;
In Docker-based deployments, run this worker as a separate service or process with its own health checks and restart policy. It should be safe to restart, safe to retry, and observable through metrics or structured logs. Do not rely on a web request staying alive long enough to complete asynchronous delivery.
Make history queryable without slowing the product
History tables grow. That is expected, but it changes indexing and retention decisions. Start with the queries people will actually run: “show this order’s timeline,” “find failed webhook deliveries from yesterday,” or “list permission changes for this account.” Index those access paths, often beginning with a foreign key plus descending event time.
CREATE INDEX order_status_history_order_time_idx
ON order_status_history (order_id, changed_at DESC);
Avoid loading an entire history collection whenever a PHP entity is loaded. Timelines should be paginated endpoints or explicit repository queries. For dashboards, calculate summaries into purpose-built tables or materialized read models rather than repeatedly aggregating a large event stream during page requests.
Retention needs the same discipline. Some records may need long-term preservation; others may be suitable for aggregation, archival, or deletion under a documented policy. “Keep everything forever” is not an architecture. It is a deferred performance, cost, and governance decision.
Let the database tell a coherent story
Databases that remember how they are used give teams a durable narrative of the system. They reveal whether a failure was retried, whether a state changed through an API or a background worker, and whether an integration behaved as expected.
The goal is not perfect historical reconstruction of every CPU instruction or HTTP header. The goal is a trustworthy record of meaningful behavior, written atomically, protected appropriately, and easy to query when it matters. Build that record intentionally, and the database becomes more than storage: it becomes one of the clearest explanations of how the system works.