Encapsulate External Services and Model Flow Explicitly
Encapsulate External Services and Model Flow Explicitly
The Standard
Wrap third-party/HTTP APIs behind a dedicated client class instead of inlining HttpClient calls in Program.cs or business logic. Give raw text/data a real type (with behavior) instead of passing strings and manipulating them with scattered LINQ/loops. Replace boolean-flag-driven while loops with an explicit enum/state machine describing control flow.
Why
The "before" version mixed HTTP plumbing, retry/pagination logic, markdown parsing, and file-naming into one giant top-level Program.cs method using a bool downloadSucceeded flag loop and raw JsonDocument navigation. This made the retry/skip/quit logic hard to follow, hid the "what is a README" concept inside string splitting, and made the method impossible to unit test or reuse. Extracting GitHubApiClient (encapsulating auth, headers, pagination as IAsyncEnumerable) and Markdown (encapsulating heading parsing) turns implicit string munging into explicit, testable, named operations. Replacing the ad-hoc boolean retry flag with an enum Flow { Continue, Retry, Skip, Abort } makes every possible outcome of "processing a repo" explicit and switchable.
Before (Anti-pattern)
var rawContent = string.Empty;
bool downloadSucceeded = false;
while (!downloadSucceeded)
{
var response = await http.SendAsync(request);
downloadSucceeded = response.IsSuccessStatusCode;
rawContent = await response.Content.ReadAsStringAsync();
if (!downloadSucceeded)
{
Console.Write("Retry, skip, or quit? (r/s/q): ");
string? input = Console.ReadLine()?.Trim().ToLower();
if (input == "q") return saved;
else if (input != "r") break;
}
}
var lines = rawContent.Replace("\r", "").Split('\n');
bool hasGettingStarted = lines.Any(line => line.TrimStart().StartsWith('#') && line.Contains("getting started", StringComparison.InvariantCultureIgnoreCase));After (Standard)
enum Flow { Continue, Retry, Skip, Abort };
static async Task<(Flow flow, string savedFile)> ProcessRepo(GitHubApiClient client, string filesDir, string owner, string repoName)
{
var readme = await client.GetReadme(owner, repoName);
if (readme is not null && readme.ContainsSection("getting started"))
{
var fileName = FileNameFor(owner, repoName);
await readme.SaveAsync(Path.Combine(filesDir, fileName));
return (Flow.Continue, fileName);
}
return (Flow.Skip, string.Empty);
}Rules for LLMs / Agents
- Never call
HttpClientdirectly fromProgram.cs, top-level statements, or business logic; wrap external HTTP/API calls in a dedicated client class. - Give raw payload types (markdown, JSON blobs, CSV, etc.) a wrapper type with named query methods (e.g.
ContainsSection,GetHeadings) instead of inline string splitting/LINQ scattered at call sites. - Do not encode workflow outcomes as loose
boolflags plus string comparisons (input == "r"); use anenumor discriminated union naming every outcome and switch/pattern-match on it. - Prefer
IAsyncEnumerable<T>for paginated/streaming API results instead of manualpage++loops mixed with business filtering logic. - Keep file-naming/sanitization logic as a small pure function (e.g.
FileNameFor), not inlined at the call site.
When NOT to apply
Trivial one-off scripts/spikes with a single external call and no retry/pagination logic do not need a dedicated client class — the overhead isn't justified until there's real behavior (auth, pagination, parsing) to encapsulate.