Here's a rule that sounds like it contradicts everything above: merchants is allowed to read from several backend services directly, without going through api-server at all.
That's not a violation of the star topology, because reads and writes aren't the same kind of operation and don't carry the same risk. A write changes state that other services and other requests depend on being correct; if two writes race, or a write partially applies, someone eventually has to reconcile the mess. A read changes nothing. If merchants reads a stale analytics number for a second because it asked the analytics service directly instead of relaying through the hub, nothing downstream broke. The audit-relevant path, the one where correctness actually matters, is the write path. Keep that one narrow. The read path can afford to be pragmatic.
You can see the split encoded directly in which Integration class a piece of code reaches for. This is merchants' client for actions that change a site's state:
// app/Http/Integrations/Cloud/ServerAPI.php
final class ServerAPI
{
use SendsRequests;
public function __construct(
protected PendingRequest $request,
) {}
public function createWebsite(array $payload): Response { /* ... */ }
public function createToken(string $site_id, ?string $webhook_url = null, ?bool $can_index = null): array { /* ... */ }
public function updateDomain(string $site_id, string $url, string $webhook): array { /* ... */ }
}
And this is its client for reading a merchant's own analytics, used entirely for that merchant's dashboard views:
// app/Http/Integrations/Cloud/AnalyticsAPI.php
final class AnalyticsAPI
{
use SendsRequests;
public function __construct(
protected PendingRequest $request,
) {}
public function getSiteCurrentVisitors(string $id): array { /* ... */ }
public function getSiteAggregations(string $id, array $payload): array { /* ... */ }
}
ServerAPI::createWebsite(), createToken(), and updateDomain() all change something real, so they run through api-server, the hub, exactly the way this chapter has argued a state-changing call should. AnalyticsAPI::getSiteCurrentVisitors() and getSiteAggregations() change nothing at all, so merchants reaches the analytics service directly for them. Both classes live in the same folder, follow the same shape, use the same SendsRequests trait for retry and backoff. The only difference is which side of the write/read line the methods on each one fall on, and that difference is visible just from which class a controller imports, without reading a single line of its implementation.
Keeping that split explicit is the entire benefit. A reviewer looking at a pull request doesn't need architecture context to know whether a new method belongs on ServerAPI or AnalyticsAPI, they need one question answered: does this change anything? If yes, it goes through the hub, no exceptions, no "just this once because it's faster." If no, it can go straight to the service that owns the data, because a stale read is an inconvenience and a divergent write is an incident.
Chapter 14 Summary
Between services
- [X] Every leaf talks to exactly one external provider and to the hub, never to another leaf
- [X] Every cross-service write goes through the one orchestrator, no exceptions for convenience
- [X] Each outside vendor's credentials live in exactly one service, the one that owns that capability
Across the fleet's output
- [X] A generated artifact talks back to the fleet through the same narrow, versioned contract as any other client
- [X] Fleet-management concerns stay out of the code that gets baked into a generated site
- [X] A stale read is tolerated; a divergent write is not, and the code makes that distinction visible
A single well-built API is a good day's work. A dozen of them, arranged so that none of them has to think about the other eleven, is what makes the good day repeatable. That's the whole difference between a fleet and a pile of services that happen to share a deploy key.