Skip to main content

📝 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:

FeatureConcepts used
Parse log lines without allocatingSpan & memory (Module 4)
Stream huge files line-by-lineAsync streams (Module 3)
Aggregate counts across coresConcurrency & parallelism (Module 2)
Stop on timeout / user requestCancellation (Module 3)
Compose readers, parsers, reportersDI & interfaces (Module 5)
Custom sequence helpersExtension methods / iterators (Module 1)
Prove the optimizationsBenchmarking (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.

graph TD UI["Program (composition root)
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

  1. Busiest minute (⭐): add a ConcurrentDictionary<DateTime, int> (Module 2) keyed by minute; report the minute with the most entries using LINQ.
  2. Multiple files (⭐⭐): analyze a folder of logs concurrently with Task.WhenAll or Parallel.ForEachAsync (Modules 2–3), then merge the AnalysisResults.
  3. Channel pipeline (⭐⭐⭐): restructure into a producer/consumer pipeline with a bounded Channel<string> (Module 3) — one task reads lines, several consumers parse/aggregate.
  4. Pluggable parsers via attributes (⭐⭐⭐): tag parser classes with a custom [LogFormat("...")] attribute and select one at runtime via reflection (Module 5).
  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. 🚀