📝 Lesson 5.3: Capstone Project
The grand finale. You'll build a concurrent log analyzer that reads large log files as an async stream, parses lines with zero allocations, aggregates results in parallel, supports cancellation, and is wired together with dependency injection — every advanced skill, working as one system.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Combine the entire Advanced course into one cohesive, high-performance application
- Architect it into clean layers wired with dependency injection
- Stream and parse data with async streams and spans
- Aggregate concurrently and safely, with cancellation support
- Extend and benchmark a real advanced C# system on your own
Estimated Time: 150 minutes
Project: A concurrent, low-allocation, DI-architected log analyzer.
In This Lesson
What We're Building
A log analyzer: given large log files, it counts entries by severity level (INFO/WARN/ERROR), finds the busiest minute, and reports the results — fast, with minimal allocation, across multiple cores, and cancellable if it takes too long.
Every feature draws on an Advanced module:
| Feature | Concepts used |
|---|---|
| Parse log lines without allocating | Span & memory (Module 4) |
| Stream huge files line-by-line | Async streams (Module 3) |
| Aggregate counts across cores | Concurrency & parallelism (Module 2) |
| Stop on timeout / user request | Cancellation (Module 3) |
| Compose readers, parsers, reporters | DI & interfaces (Module 5) |
| Custom sequence helpers | Extension methods / iterators (Module 1) |
| Prove the optimizations | Benchmarking (Module 4) |
Architecture
We define capabilities as interfaces and wire concrete implementations with DI (Module 5). Each layer has one job, and the analyzer depends only on abstractions — so any piece can be swapped or tested in isolation.
builds the DI container"] --> SVC["LogAnalyzer
orchestrates the run"] SVC --> READ["ILogSource
async stream of lines"] SVC --> PARSE["ILogParser
span-based, zero-alloc"] SVC --> AGG["IAggregator
parallel, thread-safe"] style UI fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style READ fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style PARSE fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style AGG fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
We'll assume log lines look like: 2026-08-07 14:03:22 ERROR Something failed.
Step 1: Model & Span Parsing
The result of parsing one line is a small immutable value — a readonly record struct (a value type with value equality, Modules 4 & Intermediate) so parsing millions of lines allocates nothing per entry:
// Model/LogEntry.cs
public readonly record struct LogEntry(DateTime Timestamp, LogLevel Level);
public enum LogLevel { Info, Warn, Error, Unknown }
The parser works over a ReadOnlySpan<char> (Module 4) — no Split, no substrings:
// Parsing/SpanLogParser.cs
public interface ILogParser
{
bool TryParse(ReadOnlySpan<char> line, out LogEntry entry);
}
public sealed class SpanLogParser : ILogParser
{
public bool TryParse(ReadOnlySpan<char> line, out LogEntry entry)
{
entry = default;
// "yyyy-MM-dd HH:mm:ss LEVEL message"
if (line.Length < 20) return false;
ReadOnlySpan<char> dateSpan = line.Slice(0, 19);
if (!DateTime.TryParse(dateSpan, out DateTime ts)) return false;
ReadOnlySpan<char> rest = line.Slice(20); // after the timestamp + space
int spaceIdx = rest.IndexOf(' ');
ReadOnlySpan<char> levelSpan = spaceIdx < 0 ? rest : rest.Slice(0, spaceIdx);
LogLevel level = levelSpan switch // pattern matching on a span
{
"INFO" => LogLevel.Info,
"WARN" => LogLevel.Warn,
"ERROR" => LogLevel.Error,
_ => LogLevel.Unknown
};
entry = new LogEntry(ts, level);
return true;
}
}
✅ Zero-allocation parsing
Every field is a view into the original line (Module 4). DateTime.TryParse and the switch both accept spans, so no throwaway strings are created — even across millions of lines. Note pattern matching (levelSpan switch) works directly on the span.
Step 2: Async Stream Reader
The source streams lines as an IAsyncEnumerable<string> (Module 3) with cancellation support — so huge files are never fully loaded into memory, and a read can be stopped:
// Reading/FileLogSource.cs
using System.Runtime.CompilerServices;
public interface ILogSource
{
IAsyncEnumerable<string> ReadLinesAsync(CancellationToken token = default);
}
public sealed class FileLogSource : ILogSource
{
private readonly string _path;
public FileLogSource(string path) => _path = path;
public async IAsyncEnumerable<string> ReadLinesAsync(
[EnumeratorCancellation] CancellationToken token = default)
{
using var reader = new StreamReader(_path); // using = disposed (Intermediate)
string? line;
while ((line = await reader.ReadLineAsync(token)) is not null)
{
yield return line; // stream each line lazily
}
}
}
💡 Everything from Module 3 in one method
async + yield return makes an async stream; [EnumeratorCancellation] flows the token in; ReadLineAsync(token) is cancellable I/O; using guarantees the reader is disposed even if enumeration stops early. Memory stays flat regardless of file size.
Step 3: Parallel Aggregation
Counting is CPU-light per line but there are millions of lines, so we batch and aggregate with thread-safe counters (Module 2). We use Interlocked on an array indexed by level — lock-free and fast:
// Aggregation/CountAggregator.cs
public sealed record AnalysisResult(long Info, long Warn, long Error, long Total);
public interface IAggregator
{
void Add(LogEntry entry);
AnalysisResult GetResult();
}
public sealed class CountAggregator : IAggregator
{
// one counter per LogLevel; Interlocked makes increments thread-safe (Module 2)
private readonly long[] _counts = new long[4];
public void Add(LogEntry entry) => Interlocked.Increment(ref _counts[(int)entry.Level]);
public AnalysisResult GetResult() => new(
Info: _counts[(int)LogLevel.Info],
Warn: _counts[(int)LogLevel.Warn],
Error: _counts[(int)LogLevel.Error],
Total: _counts.Sum());
}
💡 Batches keep cores busy
Rather than fanning out one task per line (huge overhead, Module 2), the analyzer will read a batch of lines, then Parallel.ForEach the batch — parsing and counting across cores. Interlocked handles the shared counters without locks. This balances parallel throughput against coordination cost.
Step 4: DI & Cancellation
The LogAnalyzer orchestrates the pieces — but depends only on the interfaces (Module 5), so each is swappable and testable. It reads the async stream in batches, parses+counts each batch in parallel, and honors a CancellationToken (Module 3):
// LogAnalyzer.cs
public sealed class LogAnalyzer
{
private readonly ILogSource _source;
private readonly ILogParser _parser;
private readonly IAggregator _aggregator;
public LogAnalyzer(ILogSource source, ILogParser parser, IAggregator aggregator)
{
_source = source;
_parser = parser;
_aggregator = aggregator;
}
public async Task<AnalysisResult> AnalyzeAsync(CancellationToken token = default)
{
var batch = new List<string>(capacity: 10_000);
await foreach (string line in _source.ReadLinesAsync(token)) // async stream
{
batch.Add(line);
if (batch.Count >= 10_000)
{
ProcessBatch(batch);
batch.Clear();
}
}
ProcessBatch(batch); // final partial batch
return _aggregator.GetResult();
}
private void ProcessBatch(List<string> batch)
{
Parallel.ForEach(batch, line => // parse + count across cores (Module 2)
{
if (_parser.TryParse(line, out LogEntry entry))
{
_aggregator.Add(entry);
}
});
}
}
The composition root wires everything with the DI container (Module 5):
// Program.cs
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSingleton<ILogSource>(_ => new FileLogSource("app.log"));
services.AddSingleton<ILogParser, SpanLogParser>();
services.AddSingleton<IAggregator, CountAggregator>();
services.AddSingleton<LogAnalyzer>();
using var provider = services.BuildServiceProvider();
var analyzer = provider.GetRequiredService<LogAnalyzer>();
// Cancel if it takes longer than 30 seconds (Module 3)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
AnalysisResult result = await analyzer.AnalyzeAsync(cts.Token);
Console.WriteLine($"Total: {result.Total:N0} " +
$"INFO: {result.Info:N0} WARN: {result.Warn:N0} ERROR: {result.Error:N0}");
}
catch (OperationCanceledException)
{
Console.WriteLine("Analysis timed out.");
}
✅ Every module, working together
One run touches it all: DI assembles the graph; an async stream feeds cancellable I/O; spans parse without allocating; pattern matching classifies; Parallel.ForEach + Interlocked aggregate across cores safely; a record carries the result. This is production-shaped advanced C#.
Step 5: Benchmarking
Finally, prove the design choices with BenchmarkDotNet (Module 4). Compare the span parser against a naive Split-based one, and the parallel analyzer against a sequential version:
[MemoryDiagnoser]
public class ParserBenchmarks
{
private readonly string _line = "2026-08-07 14:03:22 ERROR Disk full on node 7";
private readonly SpanLogParser _span = new();
[Benchmark(Baseline = true)]
public LogLevel NaiveSplit()
{
string[] parts = _line.Split(' '); // allocates an array + strings
return parts[2] switch
{
"INFO" => LogLevel.Info, "WARN" => LogLevel.Warn,
"ERROR" => LogLevel.Error, _ => LogLevel.Unknown
};
}
[Benchmark]
public LogLevel SpanParse()
{
_span.TryParse(_line, out LogEntry entry); // zero allocation
return entry.Level;
}
}
💡 Let the data guide you
Expect the span parser to allocate essentially nothing per call while NaiveSplit allocates an array plus substrings — and to run faster. For the full pipeline, benchmark sequential vs. parallel on a realistic file: parallel usually wins on large inputs, but measure — for tiny files the overhead (Module 2) can make sequential faster. The discipline is the same throughout: measure, don't guess.
Make It Your Own
Extend the analyzer — each challenge exercises advanced skills you've built.
🏋️ Extension Challenges
- Busiest minute (⭐): add a
ConcurrentDictionary<DateTime, int>(Module 2) keyed by minute; report the minute with the most entries using LINQ. - Multiple files (⭐⭐): analyze a folder of logs concurrently with
Task.WhenAllorParallel.ForEachAsync(Modules 2–3), then merge theAnalysisResults. - Channel pipeline (⭐⭐⭐): restructure into a producer/consumer pipeline with a bounded
Channel<string>(Module 3) — one task reads lines, several consumers parse/aggregate. - Pluggable parsers via attributes (⭐⭐⭐): tag parser classes with a custom
[LogFormat("...")]attribute and select one at runtime via reflection (Module 5). - Swap the aggregator (⭐): write an alternate
IAggregator(e.g. errors-only) and register it instead — no other code changes, proving the DI design.
✅ Clean architecture pays off
Notice how each extension is a local change — a new implementation registered in one place, or one added method — never a rewrite. That is the reward for interfaces, DI, and separation of concerns: an advanced system that stays easy to evolve.
Course Wrap-Up
🎉 What this capstone demonstrates
In one application you combined:
- Module 1 — extension methods, iterators, and a variance-aware, generic design
- Module 2 — parallel aggregation with thread-safe, lock-free counters
- Module 3 — async streams for I/O and cooperative cancellation/timeout
- Module 4 — span-based, zero-allocation parsing, proven with benchmarks
- Module 5 — an interface-driven, DI-wired, testable architecture
🎓 Look how far you've come
Across this course you moved from advanced language features to the runtime itself. You can now:
- Wield variance, extension methods, and lazy iterators fluently
- Write correct, efficient multithreaded and parallel code
- Master advanced async: cancellation, streaming, and performance
- Reason about memory and write high-performance, low-allocation code
- Measure rigorously with benchmarking rather than guessing
- Use reflection and dependency injection to build flexible, testable architectures
🚀 Where to Go Next
- Build real systems: ASP.NET Core web APIs and services, where DI, async, and these performance skills are used daily
- Data at scale: Entity Framework Core, and high-throughput data pipelines
- Go lower still: source generators,
System.IO.Pipelines, SIMD/System.Numerics, and native/AOT compilation - Distributed & cloud: messaging, resilience (Polly), and observability for production services
- Deepen the craft: software architecture, domain-driven design, and advanced testing
📚 Recommended Resources
🎓 Congratulations — you've completed Advanced C#!
From variance to the garbage collector, concurrency to clean architecture, you now command C# and the .NET runtime at an advanced level. Go build something fast, correct, and elegant. 🚀