<< Home

Chapter 20: Putting It All Together

This final chapter gathers the ideas from earlier chapters into a small working application. You will design a console tool, parse and validate input, compose types and modules into a tidy structure, apply async APIs and LINQ queries, integrate logging and configuration, and package your work so it can grow gracefully over time. The goal is to show how all these pieces settle into a smooth flow when building something practical.

Designing a small console app

A console app gives you a simple canvas for experimentation. You can read arguments, run commands, and print results. Modern .NET templates already include an async entry point and a minimal program style, so you can focus on logic rather than ceremony. Keep the heart of your application small and delegate work to dedicated classes that handle parsing, data access, formatting, or long-running operations.

A simple project layout

The following layout is enough for a small tool. Program.cs hosts the entry point. A Services folder contains helpers. A Domain folder stores core types. This encourages a calm separation between the public shell and the work underneath.

MyTool
├── Program.cs
├── Domain
│   └── User.cs
└── Services
    ├── UserStore.cs
    └── Formatter.cs
💡 Keep Program.cs thin. A thin shell is easier to test and easier to keep tidy as your project matures.

Parsing input and validation

Parsing begins with command line arguments. You might need to accept flags, numeric values, or file paths. Validation ensures your application never works with broken assumptions. Check for missing values, malformed numbers, or invalid ranges at the borders of your program so the rest of the code stays clean and predictable.

Basic argument handling

The example reads an action keyword and an optional user id. After validation it chooses a path forward. It is a small pattern, but it scales well for larger tools.

static async Task Main(string[] args)
{
  if (args.Length == 0)
  {
    Console.WriteLine("Commands: list, find <id>");
    return;
  }

  var cmd = args[0].ToLowerInvariant();
  switch (cmd)
  {
    case "list":
      await RunListAsync();
      break;

    case "find":
      if (args.Length < 2 || !int.TryParse(args[1], out var id))
      {
        Console.WriteLine("Missing or invalid id");
        return;
      }
      await RunFindAsync(id);
      break;

    default:
      Console.WriteLine($"Unknown command {cmd}");
      break;
  }
}

Validation helpers

Small helpers reduce repetition and give you named points to refine behaviour over time. They can enforce formats, ranges, or even domain rules.

static bool ValidateId(int id)
{
  return id > 0;
}

Composing types and modules

After parsing you need modules to carry the load. This is where domain types, services, and utilities come together. Compose your application from small, well-defined pieces such as repositories, parsers, or formatters. You can wire them manually or use a dependency injection container. Manual wiring is fine for tiny tools and keeps the structure visible.

A small domain type

Domain types store facts. A simple User record captures just what the example needs.

public readonly record struct User(int Id, string Email);

A service with async operations

This service simulates loading users. In a real app the service might use a database, a web API, or a file. The interface gives you a seam for testing.

public interface IUserStore
{
  Task<IReadOnlyList<User>> ListAsync();
  Task<User?> FindAsync(int id);
}

public sealed class UserStore : IUserStore
{
  private static readonly User[] _seed =
  {
    new User(1, "alba@example.com"),
    new User(2, "terra@example.com")
  };

  public Task<IReadOnlyList<User>> ListAsync()
    => Task.FromResult<IReadOnlyList<User>>(_seed);

  public Task<User?> FindAsync(int id)
  {
    var user = _seed.FirstOrDefault(u => u.Id == id);
    return Task.FromResult<User?>(user);
  }
}
💡 Compose by abstraction rather than inheritance. Interfaces and records make boundaries crisp and test-friendly.

Applying async and LINQ

Async operations keep your tool responsive, even when tasks involve network I/O or database operations. LINQ helps you filter, shape, and project data into whatever form your application needs. These two ideas work well together and keep code readable.

Listing items with transforms

The following command lists users, orders them, and selects a shaped object for display. Notice how the LINQ query expresses the shape of the output without extra scaffolding.

async Task RunListAsync()
{
  var store = new UserStore();
  var users = await store.ListAsync();

  var results =
    from u in users
    orderby u.Id
    select $"{u.Id}: {u.Email}";

  foreach (var line in results)
    Console.WriteLine(line);
}

Async lookup paths

Async code works smoothly with conditional logic. You can await operations inside branches without deadlocks as long as you keep the chain async from entry to exit.

async Task RunFindAsync(int id)
{
  var store = new UserStore();
  var user = await store.FindAsync(id);
  Console.WriteLine(user is null ? "Not found" : user.ToString());
}

Logging, configuration, and errors

Even tiny applications benefit from simple logs and external settings. Configuration files allow you to change runtime behaviour without recompiling. Structured logs add clarity when diagnosing unexpected behaviour. Handle errors with care so failures become understandable and repeatable.

Using built-in logging

.NET provides a lightweight logging abstraction in Microsoft.Extensions.Logging. You can add console logging with a few lines and pass the logger to your services.

using Microsoft.Extensions.Logging;

var loggerFactory = LoggerFactory.Create(builder =>
{
  builder.AddSimpleConsole();
});

var log = loggerFactory.CreateLogger("MyTool");
log.LogInformation("Tool starting at {time}", DateTimeOffset.Now);

Configuration with JSON

You can load JSON settings through ConfigurationBuilder. This lets you store connection strings, feature flags, or user preferences outside the codebase.

using Microsoft.Extensions.Configuration;

var config = new ConfigurationBuilder()
  .AddJsonFile("appsettings.json", optional: true)
  .Build();

var mode = config["Mode"] ?? "default";
Console.WriteLine($"Running in {mode} mode");
⚠️ Handle errors at the edges. Catch exceptions where they make sense and log them clearly. When a failure blocks normal operation return a sensible message rather than an unfiltered stack trace.

Packaging, versioning, and next steps

Once your tool works you can publish it as a self-contained executable or pack it as a NuGet tool. Versioning helps consumers understand changes, while documentation keeps your project approachable. By following semantic versioning you communicate compatibility through the version number itself.

Creating a self-contained build

You can publish a self-contained application for Windows, Linux, or macOS. This distributes the .NET runtime together with your app.

dotnet publish -c Release -r win-x64 --self-contained true

Versioning your releases

Keep version numbers predictable. Increment patch versions for small fixes, minor versions for new capabilities, and major versions for changes that break existing behaviour. Automate this whenever you integrate CI pipelines and tag releases in version control.

💡 Consider writing a README and a small changelog. Clear documentation is one of the most generous tools you can offer future maintainers or your future self.

You now have the ingredients for building expressive, modern C# and .NET applications that scale from tiny utilities to production systems. The rest is practice, curiosity, and steady improvement.