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
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);
}
}
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");
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.
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.