The Second Architecture, Switched Off by Comment
Trill contains a complete synchronous integration architecture - typed gRPC and HTTP clients, a live gRPC server, generated stubs, an options class - that was built, wired and then disabled by commenting out three lines across two repositories.
Read any one repository in this estate and you will conclude that Trill is purely event-driven. Services publish integration events to RabbitMQ topic exchanges, consumers subscribe, a saga orchestrates the one flow that needs a coordinator, and nothing calls anything synchronously except the gateway proxying HTTP. That is a coherent architecture and it is the one the code appears to implement.
Read across the repositories and a second architecture surfaces underneath it — typed clients, generated gRPC stubs, a running server, an options class, a service registration — complete, compiling, and disabled by three commented lines.
Part 13 diffed the estate's message contracts. This part is the estate lens proper: the finding that no single-repository read can produce.
Comment one, in the Saga
We met this in part 12, and it is worth re-reading with the estate in view:
public async Task HandleAsync(AdApproved message, ISagaContext context)
{
Data.AdId = message.AdId;
await _messageBroker.SendAsync(new PayAd(message.AdId));
// await _adApiClient.PayAsync(message.AdId);
}
public async Task HandleAsync(AdPaid message, ISagaContext context)
{
await _messageBroker.SendAsync(new PublishAd(message.AdId));
// await _adApiClient.PublishAsync(message.AdId);
}
Trill.Saga/src/Trill.Saga/Sagas/PublishAdSaga.cs:35-51. Two commented lines, two live ones, in the same two methods. The commented calls resolve to AdApiHttpClient, which is thirty-eight lines of perfectly ordinary typed client:
public async Task<bool> PayAsync(Guid adId)
{
var response = await _client.PutAsync($"{_url}/ads/{adId}/pay", new { adId });
return response.IsSuccessStatusCode;
}
Clients/AdApiHttpClient.cs:18-26. The interface exists. The implementation exists. It is registered at Extensions.cs:38 with AddScoped<IAdApiClient, AdApiHttpClient>(). The saga's constructor still takes it. Its target endpoints — PUT /ads/{id}/pay and PUT /ads/{id}/publish — are still live on the Ads service, because Ads exposes the same operations over HTTP and as bus commands. In enterprise-integration terms that duality is a Service Activator: one piece of business logic, reachable through two invocation styles.
So the ad-publication flow has two complete implementations. The messaging one runs. The RPC one is one uncomment away.
Comment two, in Ads
The second comment is smaller and does considerably more. Trill.Services.Ads/src/Trill.Services.Ads.Core/Extensions.cs:46-53:
builder.Services
.AddSingleton<IHttpContextAccessor, HttpContextAccessor>()
.AddScoped<IAdRepository, AdRepository>()
.AddScoped<IMessageBroker, MessageBroker>()
.AddScoped<IStoryApiClient, StoryApiHttpClient>()
.AddScoped<IUsersApiClient, UsersApiHttpClient>();
// builder.AddGrpc();
Note the ordering, because it is what makes this line a switch rather than an addition. IStoryApiClient is registered on line 50 as StoryApiHttpClient. The commented AddGrpc() on line 53 runs after it, and here is what it does:
public static IConveyBuilder AddGrpc(this IConveyBuilder builder)
{
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
var options = builder.GetOptions<GrpcOptions>("grpc");
builder.Services
.AddSingleton(options)
.AddSingleton<IStoryApiClient, StoryApiGrpcClient>()
.AddSingleton(services =>
{
var url = options.Services["stories"];
return GrpcChannel.ForAddress(url, new GrpcChannelOptions { ... });
});
return builder;
}
Clients/gRPC/Extensions.cs:11-33.
Last registration wins in the .NET service collection, so uncommenting one line swaps the transport of the Ads-to-Stories integration from HTTP to gRPC. Everything consuming IStoryApiClient keeps compiling and starts speaking HTTP/2 with a generated client instead of JSON over HTTP/1.1. That is a genuinely elegant piece of work — an interface, two implementations, one registration line as the seam, and no call-site change anywhere. It is the textbook payoff for programming against an abstraction, and it has been switched off since the first commit.
Everything behind that line is real: GrpcOptions binding a "grpc" configuration section, StoryApiGrpcClient wrapping a generated StoryService.StoryServiceClient, the Protos/stories.proto file compiled into the Ads assembly by a <Protobuf> item in the .csproj. It all builds. Dead code that compiles is dead code nothing flags — no warning, no analyser, no coverage report, because there are no tests to have coverage.
The server that has never been dialled
Now the other end of that wire, and this is the part that makes it an estate-level finding rather than a repository-level one.
Trill.Services.Stories runs a second gRPC surface. Extensions.cs:72 calls .AddGrpc(), and line 137 maps it:
.UseEndpoints(e => e.MapGrpcService<StoryServiceGrpcServer>());
The service is real and it does real work:
public override async Task<SendStoryResponse> SendStory(SendStoryCommand request, ServerCallContext context)
{
var command = new SendStory(default, Guid.Parse(request.UserId), request.Title,
request.Text, request.Tags, DateTime.Parse(request.VisibleFrom), DateTime.Parse(request.VisibleTo),
request.Highlighted);
await _commandDispatcher.SendAsync(command);
return new SendStoryResponse
{
Id = _storyRequestStorage.GetStoryId(command.Id)
};
}
Trill.Services.Stories/src/.../Infrastructure/Protos/StoryServiceGrpcServer.cs:22-34. It builds the same SendStory command the HTTP endpoint builds, dispatches it through the same in-process dispatcher, and reads the generated story id back out of IStoryRequestStorage — the neat little correlation trick Stories uses to turn a void-returning CQRS command into an HTTP Location header. Reused here to fill in a gRPC response, which is exactly the right instinct.
Stories binds it on port 5051 via ConfigureKestrel, alongside its HTTP endpoint on 5050. The server is running, in every profile, listening for a caller that will never dial.
The fingerprint in the proto
There is one more detail, and it is the kind of thing that only turns up when you open the file rather than trusting a report. Here are the first five lines of stories.proto as it exists in the Stories repository:
syntax = "proto3";
option csharp_namespace = "Trill.Services.Ads";
package stories;
Trill.Services.Stories/src/.../Infrastructure/Protos/stories.proto:1-5. The server's own interface definition generates its C# types into the client's namespace. Which is why the server implementation has to open with this:
using Trill.Services.Ads;
namespace Trill.Services.Stories.Infrastructure.Protos
{
public class StoryServiceGrpcServer : StoryService.StoryServiceBase
StoryServiceGrpcServer.cs:5-11. A class in Trill.Services.Stories importing Trill.Services.Ads to reach the base type generated from its own .proto.
The two files are byte-identical — same MD5 — which tells you the direction of travel: the proto was authored in Ads, for the client, and copied wholesale into Stories without touching the namespace. The copy direction is preserved in a using statement, the same way the Saga's copied MessageBroker.cs still names its loop variable @event while iterating commands and still logs "Publishing integration event" for a command send. In an estate whose distribution mechanism for shared code is the clipboard, these artefacts are the closest thing there is to provenance metadata.
What the estate lens actually reveals
Put the three comments together and a claim emerges that no single repository supports:
Trill contains a complete second integration architecture — synchronous, typed, gRPC- and HTTP-client-based — that was built, wired, and then switched off by commenting three lines.
The synchronous seams that remain live are worth listing next to the ones that do not, because the surviving set is not arbitrary:
| Seam | Transport | Status |
|---|---|---|
| Gateway to five services | HTTP (YARP) | live |
| Ads to Users | HTTP (UsersApiHttpClient) |
live |
| Ads to Stories | HTTP (StoryApiHttpClient) |
live |
| Ads to Stories | gRPC (StoryApiGrpcClient) |
commented out |
| Saga to Ads | HTTP (AdApiHttpClient) |
commented out |
| Web to Pusher | gRPC-Web | live, bypassing the gateway |
The two disabled seams are the two that cross a transactional boundary — the saga's commands, and the story-creation call that Ads makes when a paid ad becomes a story. The two live ones are read-shaped queries Ads makes about users and stories. That is not a random cull. Somebody replaced synchronous calls with messaging precisely where a failed call would leave a distributed operation half-done, and left them where a failure is just a failed read.
That is a real architectural decision, correctly targeted, recorded nowhere except as commented-out code. It is not in a commit — the init commit already contains all three comments — not in an ADR, not in a README, not in a comment explaining the comment. The estate's only architectural evolution is legible exclusively as the corpse of what it replaced.
Two habits, one for each side of this
Delete the corpse. Version control is the archive; a commented-out call is not documentation, because it carries none of the reasoning that makes documentation useful. Worse, it keeps its dependencies alive: AdApiHttpClient is still registered, still injected into the saga's constructor, and still executes an unguarded configuration lookup on every saga resolution — costs paid for two lines nobody runs.
Read the estate, not the repository. Nothing in Trill.Services.Stories tells you its gRPC server has no caller; nothing in Trill.Services.Ads tells you its client is disabled; nothing in Trill.Saga tells you the HTTP integration it half-references was superseded by the messaging it also implements. Each fact is a one-line observation in one repository, and the finding only exists when you hold all three at once. If your estate is more than three or four repositories, “is this code reachable?” is not a question a single-repository review can answer — and in an estate with no shared package feed and no cross-repo tooling, nobody is asking it.
One part left. Trill has Jaeger, Prometheus, Grafana, Seq, Consul, Vault and a correlation identifier that genuinely spans HTTP and AMQP — and no circuit breaker, no dead-letter queue, no health check and no alert rule anywhere. The retrospective: everything observed, nothing survivable.