Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/an-ad-is-a-story-with-a-flag && read --section="top" 0%
Microservices

An Ad Is a Story With a Flag

Trill's entire advertising product resolves to one boolean on somebody else's entity - a published ad is a Stories-service story with Highlighted set true - and the flag is priced at a hundred units a day while the same field is free on the public endpoint.

By Kumar Chandrachooda 27 Dec 2025 7 min read
Identical items in a row where one carries a small raised flag that nothing downstream reads

Every product with a paid tier eventually answers the same question: is the paid thing a separate entity, or is it the free thing with a privilege attached? Both answers work. The one that fails is the third: the free thing with a field attached, where the field is what you charge for and nothing in the system treats it as a privilege. Part 12 found Trill's analytics service persisting an ad discriminator it never queries. This part goes upstream to find out what that discriminator actually is.

The whole product, in one object initialiser

Trill.Services.Ads.Core/Commands/Handlers/PublishAdHandler.cs, lines 33 to 43:

ad.Publish();
var storyId = await _storyApiClient.SendStoryAsync(new SendStoryRequest
{
    UserId = ad.UserId,
    Title = ad.Header,
    Text = ad.Content,
    Tags = ad.Tags,
    Highlighted = true,
    VisibleFrom = ad.From,
    VisibleTo = ad.To
});

That is publication. A paid, admin-approved, scheduled advertisement becomes a POST /stories to the Stories service with Highlighted = true and the booked window as its visibility. There is no downstream ad entity, no ad-specific event, no impression record, no separate collection. Once an ad is published it stops existing as an ad; it is a story that renders with a different border colour.

The border colour is literal. Trill.Web.UI/Components/Story.razor:9:

<Card Bordered="true" Title="@Model.Title"
      Style="@(Model.Visibility?.Highlighted is true ? "border-color: lightskyblue; width: 95%" : "width: 95%")">

That is the only consumer of Highlighted in the entire estate. Timeline stores it in its Redis JSON and never reads it. Analytics persists it on the document and never queries it. The Pusher's protobuf contract omits visibility altogether, so a pushed ad arrives in the live feed with Visibility == null and loses its highlight entirely.

What the flag costs, and what it costs

Core/Domain/Ad.cs:56, the last line of the constructor:

Amount = (int) Math.Floor((To - From).TotalDays) * 100;

One hundred units per whole booked day. That is the complete commercial model — no CPM, no CPC, no impressions, no reach, no tag premium, no minimum spend. 100 is a bare literal with no constant, no configuration and no comment, and there is no currency field anywhere in the estate, which becomes a visible defect in the UI: @Model.Amount.ToString("C") renders using the browser's culture, so the same ad shows as $700.00 to one user and 700,00 € to another, for one number that means neither.

Math.Floor is the sharp edge. The only period rule in the constructor is from >= to, so a 23-hour booking is legal, floors to zero days, and costs zero. PayAdHandler then calls ChargeFundsAsync(userId, 0m), which succeeds, and AdPaid is published. A free ad is one date picker away.

The client duplicates the rule rather than asking for it. Trill.Web.UI/Pages/CreateAd.razor:

private void CalculateAmount()
{
    if (To <= From)
    {
        return;
    }

    TotalDays = (int)Math.Floor((To - From).TotalDays);
    Amount = TotalDays * 100;
    StateHasChanged();
}

Ad.cs:56 reimplemented in the browser, across a repository boundary, with no shared contract and no test. The two agree today by coincidence, and the browser's copy is the number the user sees before they commit to paying.

And the same flag is free through the front door

Here is the part that reframes everything above. The SendStory command in the Stories service — the one the public POST /stories endpoint binds — is this:

public SendStory(long storyId, Guid userId, string title, string text, IEnumerable<string> tags,
    DateTime? visibleFrom = null, DateTime? visibleTo = null, bool highlighted = false)

highlighted is a parameter of the public command. The service's own .rest fixture sends it:

{
  "userId": "{{userId}}",
  "title": "Test 1",
  "text": "Lorem ipsum text",
  "tags": ["dotnet", "csharp"],
  "visibleFrom": null,
  "visibleTo": null,
  "highlighted": false
}

SendStoryHandler passes it straight into new Visibility(from, to, command.Highlighted) with no check of any kind — no role, no policy, no IAppContext (which, as part 9 established, is injected nowhere). The thing the Ads service charges a hundred units a day for is a boolean anyone can set for free on the public story endpoint, along with an arbitrary visibility window. The entire approval-and-payment workflow protects a field that has no gate behind it.

That is the diagnosis of the whole design. A paid tier expressed as a field on a shared entity is only a paid tier if something enforces who may set the field. Here the enforcement lives in a different repository's workflow, and the field's owner does not know the workflow exists.

A state machine with three values and five timestamps

The Ads domain is otherwise carefully written — invariants in the constructor, private setters, guard clauses — which makes its one structural choice more interesting. AdState has three values:

public enum AdState
{
    New,
    Approved,
    Rejected
}

But the lifecycle has five stages, and the missing two are carried by nullable timestamps:

public void Pay()
{
    if (State != AdState.Approved)
    {
        throw new CannotChangeAdStateException(Id);
    }

    PaidAt = DateTime.UtcNow;
}


public void Publish()
{
    if (State != AdState.Approved)
    {
        throw new CannotChangeAdStateException(Id);
    }

    if (PublishedAt.HasValue)
    {
        throw new CannotPublishAdAException(Id);
    }

    PublishedAt = DateTime.UtcNow;
}

Read the two together and three things fall out.

  • Publish() never checks payment. Its only preconditions are “approved” and “not already published”. PUT /ads/{id}/publish on an approved-but-unpaid ad sends the story. The only thing sequencing pay before publish is PublishAdSaga.HandleAsync(AdPaid) — in a different repository. The domain does not enforce its own commercial precondition, because in a State-only model it cannot: there is no Paid state to require.

  • Pay() is not idempotent, and Publish() is. Publish() has exactly the guard Pay() needs — if (X.HasValue) throw — and the author wrote it once, for the operation that does not move money. The only protection against a double charge is OutboxCommandHandlerDecorator, which is gated on outboxOptions.Enabled, which appsettings.development.json sets to false.

  • The client reconstructs the real state machine from the timestamps. Trill.Web.UI/Components/AdFull.razor:

    _progressStep = Model.PublishedAt.HasValue ? 3
        : Model.PaidAt.HasValue ? 2
        : Model.ApprovedAt.HasValue ? 1 : 0;
    

    Which is correct, and is precisely the four-stage machine the enum cannot express. The browser knows the domain model better than the domain does.

A fourth encoding sits next to it, in strings: Model.State == "rejected", Model.State == "new", Model.State = "approved". Those literals are produced by ad.State.ToString().ToLowerInvariant() in two Ads query handlers, in another repository, with no shared contract assembly, no constants and no test. One state machine, four independent representations: a C# enum, five nullable timestamps, lowercase string literals in Razor, and an integer step index.

What is not modelled at all

Reading PublishAdHandler end to end also settles what Trill's advertising product is, which is less than the workflow implies. Because a published ad is just a highlighted story, its audience is exactly the author's own follower graph — Timeline fans out to followers and nobody else — plus whoever happens to open the recent-stories feed. Ad targeting in Trill is "your own followers, plus passers-by." An advertiser with no followers gets no timeline delivery for their hundred units a day, and nothing in the product tells them so.

Nor is there anything to tell them with. Searching the whole estate for impression, view, click and reach returns two hits, both the word “Click” in the link text of the login and register pages. None of the four concepts exists as a field, an event or a counter anywhere. The billing model never needed them because it charges for time rather than delivery. The only ad-specific screen is the four-step workflow bar, which shows the ad's own progress and no performance data whatsoever.

Two more gaps compound it. There is no refund path anywhere in the estate — no Refund command, and AddFunds has no caller outside the Users endpoint — while PayAdHandler charges before it persists:

ad.Pay();
var fundsCharged = await _usersApiClient.ChargeFundsAsync(ad.UserId, ad.Amount);
if (!fundsCharged)
{
    throw new CannotPayAdException(ad.Id);
}

await _adRepository.UpdateAsync(ad);
await _messageBroker.PublishAsync(new AdPaid(ad.Id));

If UpdateAsync fails after ChargeFundsAsync succeeded, the user is charged and the ad has no PaidAt, so the saga never sees AdPaid and no compensation exists to undo the charge. And the ad start date is forced into the future by the client's date picker (DisabledDate="date => date <= DateTime.UtcNow"), while Timeline scores by Visibility.From and caps its read window at now — so every ad is structurally invisible in every timeline on the day it is booked.

The rule worth taking

To be fair to the model: “a paid placement is an ordinary post with a privilege” is a real and defensible product decision. Several very large platforms are built on exactly that, and it is why the ad can ride the entire existing delivery pipeline for free. The mistake is not the idea; it is stopping at the field.

If a boolean is what you sell, the boolean needs an owner, a gate and a reader. In Trill it has none of the three: the Stories service owns it but does not restrict who sets it; no policy gates it; and the only code that reads it picks a border colour. Three small changes would close all of it — drop highlighted from the public SendStory command and set it only on an internal path, require an ad reference to set it, and give the downstream consumers a reason to branch on it. The workflow, the saga, the approval step and the charge are all already built. They are guarding a field that is not locked.

Next, out to the client, where the entire design system of a twenty-component application turns out to be two CSS rules: seven lines of CSS for twenty components.