From Accidental to Intentional Architecture

From Accidental to Intentional Architecture

The momentum of building a product, shaping abstract ideas into something concrete, comes with compromises and limitations. We quickly connect components and take shortcuts to speed up deliveries. This sets the scene for unpredictability.

Even a prototype is composed of several interacting components with growing complexity. That complexity leads to emergent behaviour:

A system-level property that arises from the interaction of simpler components and is not defined by any single component.

Emergent behaviour can be neutral or positive. Here, I’m specifically interested in emergent failure modes – undefined or hard to enforce invariants, scalability issues, and unexpected runtime errors.

Architectural pressure is the force cracking the design in a specific place leading to failure modes. By recognising such pressure, we feel the drive to evolve the architecture.

It signals that requirements have outgrown current design and highlights the necessary next steps. We discover these cracks through incidents or by deliberately pushing on areas that seem sensitive.

This essay uses the initial design of a web crawler to demonstrate emergent failure modes as a concrete phenomenon, and how recognising architectural pressure can significantly expand capabilities.

Part 1. Emergent Behaviour

Meet the Crawler

I need a crawler to search for signals through changes in specific web pages. For example, new trends in software engineering.

The first version relied on an Orchestrator to drive the entire flow: initiates crawling through a seed URL, fetches the web page, process the page URLs, and only enqueue supported, normalised, unseen URLs.

Pages are processed concurrently. Each crawled page is processed sequentially as described in the diagram above.

The process function implements the pipeline above.

Orchestrator#process (click to expand for simplified Scala code)
private def process(
queue: Queue[F, Uri], inflightWork: Ref[F, Long]
)(url: Uri): F[Unit] =
// enqueue URL and track in-flight work
def enqueue(url: Uri) =
inflightWork.update(_ + 1) *> queue.offer(url)
(for
// fetch webpage
htmlPage <- fetcher.fetch(url)
// extract urls from the page
pageLinks <- linkExtractor.extract(htmlPage)
// emit all page urls to stdout
_ <- emitter.emit(pageLinks)
acceptedLinks = pageLinks.links.filter(urlFilter.accept)
// canonicalise urls
canonicalisedLinks = acceptedLinks.map(UrlNormaliser.canonicalise)
// deduplicate urls
deduplicatedLinks <- canonicalisedLinks.toVector.filterA(link =>
deduplicator.hasSeen(link).map(!_)
)
// add all valid newly discovered urls to the queue
_ <- deduplicatedLinks.traverse_(
enqueue
)
yield ())
.guarantee {
// decrease in-flight work counter
inflightWork.update(_ - 1)
}

There are 3 fundamental components powering the crawler:

  • A bounded URL queue preventing OOM
  • Fibers supporting cooperative multi-tasking
  • Orchestrator defining the data flow and key invariants such as termination.

Emergent Design Pressure

All components work as expected locally:

  • The queue blocks operations when full
  • Fibers enable concurrent page processing without blocking threads
  • Orchestrator composes the dataflow and makes sure termination is correct.

Observe the failure mode:

  • Orchestrator discoveries many URLs for each URL it processes
  • The bounded queue reaches capacity
  • Each Orchestrator concurrent task finishes processing the page
  • Since the queue is full, in-flight tasks are suspended when enqueuing newly discovered URLs
  • Since in-flight Orchestrator tasks are suspended, they cannot dequeue URLs
  • Termination depends on completing all in-flight work
  • Result: deadlock. The crawler is unable to make progress or terminate.

Components are locally sound. Their interaction leads to an undesirable global property: the application may stall with very high page URLs fan-out.

There is a missing invariant: the crawler must always be able to make progress regardless of the load.

The liveness issue is an accidental global property. It is an architectural pressure: a signal that the crawler design doesn’t meet requirements. It needs immediate attention.

The Source of Tension

That tension originates from the initial, deliberate coupling between control-flow and data-flow in the Orchestrator.

That coupling also makes it very difficult to implement politeness and fairness.

These limitations were accepted for a controlled prototype. The deadlock was not – I stumbled upon the liveness issue.

Part 2. Conscious Architectural Evolution (From Emergence to Intent)

Planning Next Steps Guided By Pressure

The liveness issue and the difficulty in implementing key features are direct consequences of the current design: the Orchestrator couples work admission, control and data processing in a single component.

These pressures point to the next architectural evolutionary steps:

  1. Convert the Orchestrator into a Worker, responsible only for processing URLs
  2. Introduce Ingress to admit URLs into the system
  3. Admission must never stall Workers (it may block on I/O)
  4. Control URL dispatch with a Scheduler.
Scheduler (algebra)
trait Scheduler[F[_]]:
def dispatch: fs2.Stream[F, Uri]

Ingress (algebra)
trait Ingress[F[_], T]:
def publish(events: T): F[Unit]
def publish(events: Vector[T]): F[Unit]
def stream: fs2.Stream[F, T]

The initial control policy is simple: the Scheduler dispatches URLs to workers at a steady rate.

The Ingress sole responsibility is transport: it’s the entry point in the system and has well-defined semantics:

  • Idempotently admit URLs into the system
  • Expose a stream where each admitted URLs is emitted exactly once.

The diagram below demonstrates the revised architecture:

FifoScheduler#dispatch
class FifoScheduler[F[_]: Temporal] private (
source: fs2.Stream[F, Uri], // from Ingress
dispatchInterval: FiniteDuration // rate limiting work admission
) extends Scheduler[F]:
def dispatch: fs2.Stream[F, Uri] =
source.metered(dispatchInterval)

Ingress.makeQueuedIngress (in-memory)
object Ingress:
def makeQueuedIngress[F[_]: Temporal, T](
queue: Queue[F, T] // unbounded queue
): Ingress[F, T] =
new Ingress[F, T]:
override def publish(event: T): F[Unit] =
queue.offer(event)
override def publish(events: Vector[T]): F[Unit] =
events.traverse_(queue.offer)
override def stream: fs2.Stream[F, T] =
fs2.Stream.fromQueueUnterminated(queue)

Worker#process
// Unbounded `targets` stream replaces queue
class Worker[F[_]: Async](targets: fs2.Stream[F, Uri], . . .):
def run = targets.parEvalMap(maxConcurrency)(process)
private def process(url: Uri): F[Unit] =
def publish(newlyDiscovered: Vector[Uri]) =
tracker.track(newlyDiscovered.size) *>
ingress.publish(newlyDiscovered)
(for
htmlPage <- fetcher.fetch(url)
pageLinks <- linkExtractor.extract(htmlPage)
_ <- emitter.emit(pageLinks)
acceptedLinks = pageLinks.links.filter(urlFilter.accept)
canonicalisedLinks = acceptedLinks.map(UrlNormaliser.canonicalise)
deduplicatedLinks <- canonicalisedLinks.toVector.filterA(link => deduplicator.hasSeen(link).map(!_))
// Publish new links to Ingress instead of offering to queue directly
_ <- publish(deduplicatedLinks)
yield ())
.guarantee {
tracker.completed()
}

These changes fix the liveness issue and unlock new options. They do not provide fairness or politeness yet, but introduce a central point where these new policies can be defined.

They also introduce a new pressure.

Pressure Shifts

In the original design, the Orchestrator both produced and buffered URLs using a single bounded queue.

Now, Workers immediately publish discovered URLs. The Ingress emits a stream of admitted URLs, and the Scheduler dispatches them to Workers at a controlled frequency. Because most webpages contain many links, Workers produce URLs far faster than they can process.

The in-memory Ingress implementation relies on an unbounded queue to hold pending URLs. This removes the deadlock, but shifts the pressure elsewhere: newly-discovered URLs grow without bounds, potentially leading to OOM.

The redesign does not eliminate the risks created by the in-memory queue. It isolates the risks.

If memory-growth becomes a problem, the in-memory Ingress can be swapped for a persistent implementation.

PostgresIngress (durable)
object PostgresIngress:
def make[F[_]](): Resource[F, Ingress[ConnectionIO, Uri]] =
Resource.pure(new PostgresIngress())
private class PostgresIngress private () extends Ingress[ConnectionIO, Uri]:
override def publish(url: Uri): ConnectionIO[Unit] =
Statements.insertUrl(url)
override def publish(urls: Vector[Uri]): ConnectionIO[Unit] =
Statements.insertUrls(urls)
override def stream: fs2.Stream[ConnectionIO, Uri] =
fs2.Stream.repeatEval(Statements.dequeueUrl).unNone

Since the architecture is defined by contracts, the other parts of the system remain unchanged.

The in-memory Ingress still provides several benefits:

  • Extremely high throughput
  • No external dependencies
  • Fully non-blocking operations.

Keeping both implementations provides options for different execution environments: simple, fast, and riskier vs. safer, slightly slower and operationally heavier.

The crawler evolution thus far:

Prototype
Deadlock
Admission (In-memory ingress)
Control / URL processing separation
Optional durable ingress.

Part 3. Politeness & Fairness (A Gentle and Effective Crawler)

As it stands, the crawler can only be used under tight control, for example by restricting it to a single domain. Otherwise it risks behaving as a DoS tool.

To unlock its full potential – extracting meaningful signal from a diverse set of webpages – it must implement politeness and fairness.

Politeness requires an interval between requests to the same domain.

Fairness preserves throughput. While waiting for the cooldown period of one domain, the crawler can continue to make progress by fetching URLs from others.

With a clear separation between admission, control and processing, supporting the new control policies can be achieved by swapping the current FifoScheduler for a more involved Coordinator. The other parts of the crawler remain intact.

Enter the Coordinator

  • The Coordinator uses a priority queue of DomainQueues to find the next eligible URL
  • A DomainQueue is initialised during admission, when the first URL for a domain arrives
  • The priority queue keeps the DomainQueue with the earliest next-eligible URL at the head, from which the next URL is taken
  • After dispatching a URL, the Coordinator advances the domain’s eligibility time by the cooldown period.

Since each domain has its own queue, hot domains do not starve lower-traffic ones.

Internally, a TreeSet ordered by (nextEligibilityAt, domain) provides priority queue semantics – preferred over a binary heap since reordering requires efficient removal, which TreeSet can handle in O(log n).

The Coordinator – the new Scheduler implementation – is more involved than the previous snippets. Both the source and the spec are available on GitHub.

The pressure that initially signaled a broken design has, in the end, helped shape a better one.

With a few remaining features in place, the crawler will reach a point where I can shift the focus from building a crawler to extracting signal from the vast web.

Reflections

In practice, the most careful and competent engineer will be surprised and think “How haven’t I seen this before?” at some point. And that is the work.

Intelligence, creativity and sophistication often compound the problem, particularly at a system-level. Early adoption of technologies, elaborate interactions between components and abstractions lead to unjustified complexity when not strongly grounded on actual necessity. This often translates into fragility. Boring and battle-tested is good.

A simple heuristic is to start simple and address emergent failure modes as they appear, identifying and isolating the architectural pressure as early as possible, using it to inform deliberate architectural evolution.

Missing invariants may be inevitable. The architectural pressure will surface them. Let it guide the next evolutionary step.

Property-Based Testing as a Design Tool

Property-Based Testing as a Design Tool

Part 1 – Motivation and Foundations

Property-based testing is a powerful tool for designing correct, unambiguous programs through executable specifications.

It helps us answer an essential question:

What types of universal claims about a program are worth defining and enforcing?

A property is a proposition of the form:

x1D1,x2D2,,xnDn,P(x1,,xn)\forall \; x_1 \in D_1,\; x_2 \in D_2,\; \ldots,\; x_n \in D_n,\; P(x_1,\ldots,x_n)

where:

  • Each DiD_i represents the domain of discourse for which the operation is defined (the set of possible values for each input parameter).
  • PP is a logical predicate describing the program’s behaviour.

Property-based tests do not attempt to prove such universal claims.

Instead, they try to falsify them by sampling the input domain in search of counterexamples. A single counterexample is enough to invalidate the claimed property (Popperian philosophy).

The Domains DiD_i and Generators

Property-based testing needs concrete representations of the domains DiD_i over which a property is quantified. Generators encode how to sample values from the space of inputs a component operates on.

Property-based tests aim to ensure that properties hold across the entire domain DiD_i, not merely for a curated set of examples.

The randomness in generators serves not to produce random tests, but to increase the chances that the domain is well-sampled. Since sampling can be biased, falsification is only as effective as the generators it depends on.

The quality of a generator directly affects test effectiveness: a missing counterexample in an uncovered region of the domain will remain undetected.

The Predicate PP

Predicates describe the externally observable behaviour of a component that must remain true: its contract.

They:

  • Encode reference implementations, algebraic laws, relations, domain rules.
  • Are agnostic to implementation details.

Effective predicates lead to reduced ambiguities and clear understanding of component semantics.

This is particularly valuable when composing components, reasoning about interaction between operations, or designing stateful components, where behaviour emerges from a sequence of actions.

Preconditions

The domain of discourse can be further restricted using implications and preconditions:

x1D1,,xnDn,Pre(x1,,xn)Post(x1,,xn)\forall x_1 \in D_1,\; \ldots,\; x_n \in D_n,\; \operatorname{Pre}(x_1,\ldots,x_n) \Rightarrow \operatorname{Post}(x_1,\ldots,x_n)

Preconditions define the conditions in which a property must always hold, such as valid inputs or states.

They restrict the domain of discourse and may reduce the chances of finding a counterexample if they shrink the input space excessively.

Part 2 – Property-Driven Design in Practice

These ideas become concrete when applied to a real system design.

Suppose we are tasked with building a Scheduling API. Such components provide critical operations: checking availability, detecting overlaps, manipulating time spans, to name a few. Scheduling will be built on top of more fundamental components: discrete intervals.

A sensible first step is to model intervals as ranges of numbers rather than timestamps. A minimal structure consisting of start and end, preserving start < end, may appear sufficient. It rarely is.

This is where ambiguity begins – the unspoken assumptions:

  • Does containment include the endpoints?
  • When do two intervals intersect?
  • Fundamentally: how do interval operations relate to each other?

None of these questions are answered by the data structure alone. I’ve tried extensive documentation (still important, but complementary) as well as large types and function names, without significant improvement.

Ambiguity remained. And worse, it created a false sense of understanding, which eventually led to unpredictable behaviour, particularly when composing operations.

Domain Pressure Trims the Design

Scheduling systems care about overlapping, adjacency, ordering, containment of time spans.

Rather than trying to encode the complete theory of intervals, the Interval API is restricted to the set of operations required to express and support schedule invariants.

Domain-Driven Interval Properties

The following tables map required scheduling features to the interval semantics needed to support them, and the corresponding properties that enforce them.

Validity and Error Modes

Components are expected to reject, sanitise or fail fast when the input is invalid.

Scheduling ConcernInterval Properties (Spec)Formal Property
Valid schedules are constructibleSmart constructors accept all valid bounds(s,e)𝒯.valid(s,e)constructor(s,e) \forall (s, e)\in\mathcal{T}.\, \\ \operatorname{valid}(s, e) \Rightarrow \operatorname{constructor}(s, e) \downarrow
Invalid schedules are rejectedSmart constructors reject invalid bounds(s,e)𝒯.¬valid(s,e)constructor(s,e) \forall (s, e)\in\mathcal{T}.\, \\ \neg\operatorname{valid}(s, e) \Rightarrow \operatorname{constructor}(s, e) \uparrow
Conflicts & Coexistence

Scheduling systems must detect overlapping schedules to detect conflicts, allow new disjoint ones to coexist, handle overrides.

Scheduling ConcernInterval Properties (Spec)Formal Property
Overlapping schedules are detectedIntersecting intervals report intersection(a,b)intersecting.intersects(a,b) \forall (a,b)\in\mathcal{I}_{\text{intersecting}} \; .\, \\ \operatorname{intersects}(a,b)
Disjoint schedules are allowedDisjoint intervals never intersect(a,b)disjoint.¬intersects(a,b) \forall (a,b)\in\mathcal{I}_{\text{disjoint}} \; .\, \neg \operatorname{intersects}(a,b)
Overlapping detection is consistent
(A conflicts with B ⇔ B conflicts with A)
Intersection is symmetrica,b.intersects(a,b)intersects(b,a)\forall a,b.\\ \operatorname{intersects}(a,b) \iff \operatorname{intersects}(b,a)
Overlapping imply conflictsContainment implies intersectiona,b.contains(a,b)intersects(a,b)\forall a,b.\\ \operatorname{contains}(a,b) \Rightarrow \operatorname{intersects}(a,b)
Continuity, Adjacency and Gaps

Schedule systems need to understand if schedules are back-to-back (adjacent) or if there is room for new ones between them.

Scheduling ConcernInterval Properties (Spec)Formal Property
Back-to-back schedules do not conflict
(no hidden overlaps at the boundaries)
Adjacent intervals never intersect(a,b)adjacent.¬intersects(a,b) \forall (a,b)\in\mathcal{I}_{\text{adjacent}} \; .\, \neg\operatorname{intersects}(a,b)
Partial Order

Ensuring that contains forms a partial order enables safe composition, local reasoning about nested structures (avoiding examining entire hierarchies), and defines proper equality semantics.

Scheduling ConcernInterval Properties (Spec)Formal Property
Every schedule contains itselfContainment is reflexivea.contains(a,a)\forall a. \operatorname{contains}(a, a)
Nested schedules compose safely
(safe chaining of containment checks)
Containment is transitivea,b,c.contains(a,b)contains(b,c)contains(a,c)\forall a,b,c.\\ \operatorname{contains}(a,b)\land \operatorname{contains}(b,c) \Rightarrow \\ \operatorname{contains}(a,c)
Uniqueness is well-defined
(equality checks, deduplication)
Containment is antisymmetrica,b.contains(a,b)contains(b,a)a=b\forall a,b. \\ \operatorname{contains}(a,b)\land \operatorname{contains}(b,a) \Rightarrow a = b

A Few Curated Examples

Four Interval Types, One Consistent API

Grouping the properties that must hold across all interval types goes beyond DRY. It is a powerful tool for designing unified APIs with consistent, well-defined semantics.

  checkIntervalProperties("closed", Intervals.makeClosed[Int])
  checkIntervalProperties("open", Intervals.makeOpen[Int])
  checkIntervalProperties("half-open right", Intervals.makeHalfOpenRight[Int])
  checkIntervalProperties("half-open left", Intervals.makeHalfOpenLeft[Int])

Crucially, not all operations are total: some are undefined for certain interval types. For example, adjacency has no meaning for open intervals, because open intervals have no boundary points.

Property-based testing must navigate this partial landscape enforcing laws only where operations are defined.

Encoding Validity Through Construction

Smart constructors (makeClosed, makeOpen, and similar factory functions) ensure that only intervals satisfying their invariants can be constructed.

The behaviour of interval smart constructors is specified using implications:

(s,e)𝒯.valid(s,e)constructor(s,e)\forall (s,e) \in \mathcal{T}.\; \text{valid}(s,e) \Rightarrow \text{constructor}(s,e)\downarrow

This states that construction must succeed whenever the validity precondition holds.

In practice, instead of encoding this implication directly in the tests, we restrict the generator to produce only inputs satisfying the precondition.

This preserves the specification while increasing the chances of falsification and making the domain of discourse explicit.

    property(s"$name intervals are valid"):
      forAll(properBounds) { case (start, end) =>
        val result = factory(start, end)
        alg.validBounds(result.start, result.end) &&
        result.start == start &&
        result.end == end
      }

Conversely, when the input is invalid, the smart constructor must not construct an interval.

(s,e)𝒯.¬valid(s,e)constructor(s,e) \forall (s, e)\in\mathcal{T}.\, \\ \neg\operatorname{valid}(s, e) \Rightarrow \operatorname{constructor}(s, e) \uparrow

In this design, invalid inputs result in immediate error rather than a recoverable value (such as Option or Either): trying to create an interval is considered a bug, not a valid use case.

    property(s"$name intervals reject invalid bounds"):
      forAll(reversedBounds) { case (start, end) =>
        throws(classOf[IllegalArgumentException]) {
          factory(start, end)
        }
      }

Together, these two properties specify the observable success and failure behaviour of interval construction over the domain of bounds.

Domain-Specific Generators

In the IntervalSpec, most interval properties are defined over specific domain subsets, rather than through implications and preconditions. For example:

(a,b)disjoint.¬intersects(a,b) \forall (a,b)\in\mathcal{I}_{\text{disjoint}} \; .\, \neg \operatorname{intersects}(a,b)

These properties require custom generators that sample directly from the relevant domain of discourse (disjoint\mathcal{I}_{\text{disjoint}} in this case), rather than relying on implications and on discarded invalid samples.

Examples of such interval generators include: properIntervals, adjacentIntervals, intersectingIntervals, and disjointIntervals.

Non-Total Properties and Vacuous Truth

Adjacency is not defined for all interval types. In particular, it is undefined for open intervals: for example, the open intervals (6, 7) and (7, 8) are not adjacent since they do not share a boundary point.

Rather than expressing adjacency laws through implications, we can define them over a restricted domain:

(a,b)adjacent.¬intersects(a,b) \forall (a, b)\in\mathcal{I}_{\text{adjacent}} \; .\, \neg\operatorname{intersects}(a, b)

This formulation also holds for open intervals, but only vacuously. When adjacent=\mathcal{I}_{\text{adjacent}} = \emptyset, the property still holds, since x,P(x)true\forall x \in \emptyset,\; P(x) \equiv \text{true}.

We can encode this behaviour directly in the generator. The adjacentIntervals generator produces:

  • Some((a,b)) when a pair of adjacent intervals exists (closed or half-open intervals)
  • None when adjacency is undefined (open intervals).

The property can then be written as:

    property(s"adjacent $name intervals never intersect"):
      // Vacuously true when no adjacent intervals exist
      forAll(adjacentIntervals) { adjs =>
        adjs.forall { case (a, b) => a.isAdjacent(b) &amp;&amp; !a.intersects(b) }
      }
Relationships, Implications and Vacuity

Some relationships between operations are inherently conditional.

For example, consider the relationship between containment and intersection: if an interval contains another, they intersect. Formally:

a,b.contains(a,b)intersects(a,b)\forall a,b.\\ \operatorname{contains}(a,b) \Rightarrow \operatorname{intersects}(a,b)

In such cases, tests are often expressed as implication. Using De Morgan’s laws, implications can be rewritten into a form more suitable for programming languages:

PQ¬PQP \Rightarrow Q \;\equiv\; \neg P \lor Q

However, such implication-based properties are prone to vacuous truth and may convey no information at all if the antecedent rarely holds.

property(s"containing intervals intersect for $name intervals"):
  forAll(properIntervals, properIntervals) { case (a, b) =>
    // a.contains(b) ⇒ a.intersects(b)
    !a.contains(b) || a.intersects(b)
  }

If !a.contains(b) always evaluates to true, the test will always succeed, even if a.intersects(b) is broken. This typically occurs when the generator rarely produces input satisfying the antecedent.

Takeaway

Through this lens, property-based testing is not primarily about testing. It is about deliberately constraining API semantics to what the domain demands.

Collapsing an unbounded space of possible behaviours into a small set of precise properties is difficult – but necessary. Ambiguous components do not compose into reliable systems.

Deadlines and pressures are real, but core quality cannot be negotiated. Some parts of a system must be correct, predictable, and composable by design.

The goal is to build systems that fulfill their objectives. Property-based testing is one of the best tools for designing the composable and predictable components that make this possible.

Links