C# Fundamentals: From Your First Program to Async and JSON · Modern C# in Practice: Generics, Async, Errors and JSON

Capstone: An Inventory Processor

Combine records, LINQ, pattern matching, async loading and JSON in one program that loads stock data, validates it, summarizes it and writes a report.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

An inventory processor. It loads raw stock rows asynchronously from two simulated sources, validates them into typed records (rejecting bad rows with reasons), summarizes stock by category with LINQ, flags low stock with pattern matching, and writes a JSON report.

The idea

This lesson reuses the whole course:

  • Records model validated data; a Result-style discriminated approach reports good and bad rows.
  • Async + Task.WhenAll load both sources concurrently.
  • LINQ groups and aggregates; switch expressions with patterns classify stock levels.
  • System.Text.Json produces the report.

Design rules to take away:

  1. Validate at the edge; the rest of the code works with trusted types.
  2. Return rich results (a value or a reason), not null or exceptions, for expected failures.
  3. Keep each function small and testable; pass dependencies in.

The code

Load, validate, summarise and report
using System.Text.Json;

// 1. Load two sources concurrently
var (warehouseA, warehouseB) = (LoadAsync("A", 40), LoadAsync("B", 20));
var raw = (await Task.WhenAll(warehouseA, warehouseB)).SelectMany(rows => rows).ToList();
Console.WriteLine($"loaded {raw.Count} raw rows");

// 2. Validate into typed records, collecting problems
var items = new List<Item>();
var rejected = new List<string>();
foreach (var row in raw)
{
    switch (Validate(row))
    {
        case (Item item, null):
            items.Add(item);
            break;
        case (null, string reason):
            rejected.Add($"{row.Sku}: {reason}");
            break;
    }
}

foreach (var reason in rejected) Console.WriteLine("rejected " + reason);

// 3. Summarise with LINQ
var byCategory = items
    .GroupBy(i => i.Category)
    .Select(g => new CategorySummary(g.Key, g.Count(), g.Sum(i => i.Quantity), g.Sum(i => i.Quantity * i.UnitPrice)))
    .OrderByDescending(s => s.Value)
    .ToList();

foreach (var s in byCategory)
    Console.WriteLine($"{s.Category,-10} {s.Items} items, {s.Units,4} units, value {s.Value,9:N2}");

// 4. Classify stock levels with patterns
foreach (var item in items.OrderBy(i => i.Sku))
{
    var status = item switch
    {
        { Quantity: 0 } => "OUT OF STOCK",
        { Quantity: < 10, Category: "tools" } => "low (tools reorder quickly)",
        { Quantity: < 10 } => "low",
        _ => "ok",
    };
    if (status != "ok") Console.WriteLine($"  {item.Sku,-8} {status}");
}

// 5. A JSON report
var report = new Report(items.Count, rejected.Count, byCategory);
var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
Console.WriteLine(json);

static async Task<List<RawRow>> LoadAsync(string warehouse, int delayMs)
{
    await Task.Delay(delayMs);                  // pretend this is a network or database call
    return warehouse == "A"
        ?
        [
            new("W-100", "hammer", "tools", "25", "12.50"),
            new("W-101", "saw", "tools", "4", "30.00"),
            new("W-102", "drill", "tools", "x", "80.00"),
            new("L-200", "lamp", "home", "60", "15.00"),
        ]
        :
        [
            new("L-201", "bulb", "home", "300", "1.20"),
            new("L-202", "shade", "home", "0", "9.00"),
            new("G-300", "gloves", "garden", "8", "-3.00"),
            new("G-301", "hose", "garden", "12", "22.00"),
        ];
}

static (Item?, string?) Validate(RawRow row)
{
    if (!int.TryParse(row.Quantity, out var quantity)) return (null, $"quantity '{row.Quantity}' is not a number");
    if (!decimal.TryParse(row.Price, System.Globalization.CultureInfo.InvariantCulture, out var price)) return (null, "price is not a number");
    if (price < 0) return (null, "price must not be negative");
    return (new Item(row.Sku, row.Name, row.Category, quantity, price), null);
}

record RawRow(string Sku, string Name, string Category, string Quantity, string Price);
record Item(string Sku, string Name, string Category, int Quantity, decimal UnitPrice);
record CategorySummary(string Category, int Items, int Units, decimal Value);
record Report(int Accepted, int Rejected, List<CategorySummary> Categories);

Output

loaded 8 raw rows
rejected W-102: quantity 'x' is not a number
rejected G-300: price must not be negative
home       3 items,  360 units, value  1,260.00
tools      2 items,   29 units, value    432.50
garden     1 items,   12 units, value    264.00
  L-202    OUT OF STOCK
  W-101    low (tools reorder quickly)
{"accepted":6,"rejected":2,"categories":[{"category":"home","items":3,"units":360,"value":1260.00},{"category":"tools","items":2,"units":29,"value":432.50},{"category":"garden","items":1,"units":12,"value":264.00}]}

Run this on your computer — see “Get Started” for setup.

Try extending it: add a warehouse field to Item, group by it, or write the report to a file with File.WriteAllTextAsync. Then replace the simulated LoadAsync with a real HttpClient call.

Common mistakes

  • Validating deep inside business logic instead of at the boundary. Convert raw data to typed records once, up front.
  • Losing the reason for a rejection. Return the reason with the failure so problems can be fixed at the source.
  • Awaiting the sources one after another. Independent loads belong in Task.WhenAll.
  • Putting everything in one giant method. Split loading, validating, summarizing and reporting so each is testable.
  • Hard-coding data sources. Pass them in (an interface or delegate) so tests can supply fake data.

Key points

  • Validate raw input into typed records at the edge, collecting both items and reasons for rejection.
  • Load independent sources concurrently with Task.WhenAll.
  • LINQ groups and aggregates; switch expressions with property patterns classify cases readably.
  • System.Text.Json turns the final records into a report.

Further reading: C# documentation.

Deliverables

Why return a (value, error) pair or a result object from `Validate` instead of throwing for each bad row?

Model answer

Bad rows are an expected part of importing data, not exceptional. Returning the reason lets the program keep processing the remaining rows and report every problem, and it avoids the cost and flow-control confusion of exceptions.

How does `Task.WhenAll` help when loading the two warehouse sources?

Model answer

It starts both loads before waiting, so they run concurrently and the total time is about the slower one rather than the sum of both.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?