File-Based Apps in .NET 11: Room to Grow
Sometimes you just need a little C#. Count some files. Generate a report. Check a configuration value. The work might take twenty lines, but first you create a console project, choose a name, and decide where it belongs in the solution.
File-based apps let you start with the code. Save a .cs file and run it. When the utility needs a package or another source file, you can add those too.
.NET 11 makes that second part much more useful. We get more ways to share code, manage dependencies from the command line, and keep a small tool organized as it grows. Let’s walk through it.
What Was Already There?
File-based apps arrived in .NET 10. Running a C# file, declaring NuGet packages with #:package, referencing a project with #:project, and choosing an SDK with #:sdk are part of that foundation. The SDK supplies the project configuration behind the scenes. See the original file-based app announcement for the starting point.
Here’s the distinction to keep in mind as we go:
| Capability | Where it fits |
|---|---|
Run a .cs file without maintaining a .csproj | Introduced in .NET 10 |
Include additional source files with #:include | Available in .NET 11 and backported to SDK 10.0.300 |
| Add, list, and remove dependencies using CLI commands targeting a file | Expanded in .NET 11 |
Reference another file-based app using #:ref | Added in .NET 11 |
| Use quoted property values and dependency metadata | Expanded in .NET 11 |
The examples below target the .NET 11 SDK. Check dotnet --version from your working directory, particularly if your repository has a global.json selecting an older SDK. The .NET 11 SDK overview covers the tooling changes.
Start With One File
Create an empty directory and add report.cs:
var folder = args.Length > 0 ? args[0] : ".";
var count = Directory.GetFiles(folder, "*.md").Length;
Console.WriteLine($"Found {count} Markdown files.");
Run it from that directory:
dotnet run report.cs -- .
The argument after -- becomes an application argument. This example counts Markdown files directly inside the selected folder; it doesn’t search subdirectories.
If you’re working near an existing project, make your intent explicit:
dotnet run --file report.cs -- .
That avoids having dotnet run select a project and pass your filename to that project’s application. I would still keep little utilities in their own folder so the repository stays easy to navigate.
Add a NuGet Package
Let’s make the output handle singular and plural words. We’ll use Humanizer, with a fixed version so the example is repeatable:
dotnet package add Humanizer.Core --version 2.14.1 --file report.cs
The command writes a directive into the C# file. Update the code so report.cs contains:
#:package Humanizer.Core@2.14.1
using Humanizer;
var folder = args.Length > 0 ? args[0] : ".";
var count = Directory.GetFiles(folder, "*.md").Length;
Console.WriteLine($"Found {"Markdown file".ToQuantity(count)}.");
You can type that directive yourself, too. The useful CLI improvement is being able to manage the dependency directly against the file:
dotnet package list --file report.cs
dotnet package remove Humanizer.Core --file report.cs
The remove command is an example, not a required next step: the code still uses Humanizer. If you try removing it, add it again before continuing.
The package commands operate on the app’s generated project configuration. You don’t need to convert the utility into a conventional project just to add a dependency. This support was introduced during .NET 11 Preview 5.
For a standalone example, pin the package version. A versionless #:package is appropriate when the version comes from Directory.Packages.props. If your repository already uses it, see my central package management walkthrough.
Split the App Across Multiple Files
Now let’s move the reporting logic into a helper. Our folder will contain:
markdown-report/
report.cs
FileReport.cs
Replace report.cs with:
#!/usr/bin/env dotnet
#:include FileReport.cs
#:package Humanizer.Core@2.14.1
var folder = args.Length > 0 ? args[0] : ".";
Console.WriteLine(FileReport.Summarize(folder));
Then create FileReport.cs:
using Humanizer;
public static class FileReport
{
public static string Summarize(string folder)
{
var count = Directory.GetFiles(folder, "*.md").Length;
return $"Found {"Markdown file".ToQuantity(count)}.";
}
}
Run the same command:
dotnet run --file report.cs -- .
#:include compiles the helper into the same application. Merely placing another .cs file beside the entry point doesn’t include it automatically. Also, keep top-level statements in the entry file; included files contain declarations such as classes and methods. Microsoft’s file-based app reference documents the supported file types and the SDK 10.0.300 backport.
The first line, #!/usr/bin/env dotnet, helps tooling identify the entry point of a multi-file app. Leave it in place on Windows too; run the file with dotnet as shown above. The SDK’s file-based program specification explains that convention.
For a larger collection of helpers, an include can use a glob:
#:include helpers/**/*.cs
Keep the pattern narrow enough that it won’t pull in another app’s entry point. The current documentation also notes that glob includes disable the file-based build cache. Explicit filenames are a useful default for a small utility.
Add a Reference to an Existing Project
Maybe your repository already has a library containing the logic you need. There’s no reason to copy that code into the utility.
Given a SharedTools/SharedTools.csproj library beneath the current directory, add it with:
dotnet reference add --file report.cs SharedTools/SharedTools.csproj
That adds:
#:project SharedTools/SharedTools.csproj
You can now use the library’s accessible types, adding the appropriate using statements. Its target framework must be compatible with the app.
The corresponding management commands are:
dotnet reference list --file report.cs
dotnet reference remove --file report.cs SharedTools/SharedTools.csproj
The directive itself predates .NET 11. The new part is the dotnet reference workflow with --file, as documented in the reference command documentation.
Reference Another File-Based App
Including source is useful when it belongs to the same compilation. .NET 11 also introduces #:ref, which gives another C# file its own generated project and assembly.
Here’s a separate, self-contained example. Create greet.cs:
#!/usr/bin/env dotnet
#:ref GreetingLibrary.cs
Console.WriteLine(GreetingLibrary.Greet("Barret"));
And GreetingLibrary.cs:
#:property OutputType=Library
public static class GreetingLibrary
{
public static string Greet(string name) => $"Hello, {name}!";
}
Run it with:
dotnet run --file greet.cs
Set OutputType=Library because this referenced file doesn’t have an entry point. Make shared types public because they live in another assembly. References can also be transitive: one referenced file can reference another. These details are described in the SDK directive specification.
Here’s how I would choose among the options:
| What I need | Directive |
|---|---|
| Compile a helper alongside my entry point | #:include FileReport.cs |
| Reuse a library that already has a project | #:project SharedTools/SharedTools.csproj |
| Give another C# file its own assembly | #:ref GreetingLibrary.cs |
| Consume a NuGet package | #:package Humanizer.Core@2.14.1 |
| Reference a compiled DLL directly in .NET 11 | #:include libs/SharedTools.dll |
The last option requires an existing compatible DLL; it doesn’t build a library project for you. DLL inclusion is documented in the supported directives reference.
Richer Directives and More CLI Support
.NET 11 also lets directives express more of the configuration that might otherwise push you toward a project file. For example, a property can have a quoted value containing spaces:
#:property Company="Barret Codes"
#:property AssemblyTitle="Markdown Report"
Dependency directives can carry MSBuild item metadata. For a package that should be private to the consuming project, the syntax is:
#:package Humanizer.Core@2.14.1 PrivateAssets=all
PrivateAssets=all controls whether the dependency flows to consumers; it doesn’t prevent this application from using it. The .NET 11 SDK documentation covers quoted values and metadata.
Another handy addition is formatting the app directly:
dotnet format report.cs
.NET 11 also improves repeated launches by allowing the Native AOT CLI path to reuse compatible cached outputs for an unchanged file-based app. That’s a tooling optimization, not a claim that every dotnet run performs a native publish. See the SDK’s cached launch notes.
Build It, Share It, or Give It a Project
The existing file-based workflow still gives you the usual next steps:
dotnet build report.cs
dotnet publish report.cs
dotnet pack report.cs
Publishing defaults to Native AOT, so the target platform needs its native build prerequisites and your dependencies need to support that publishing model. To opt out, put this directive near the top of the entry file:
#:property PublishAot=false
Packing defaults to creating a .NET tool package. Those publishing and packaging defaults are covered in the file-based app guide; they aren’t new .NET 11 features.
Eventually, the tool may need enough build customization or project structure that maintaining a .csproj becomes useful. Convert it:
dotnet project convert report.cs
That gives you an explicit project to maintain. Review the generated files and build the converted project before switching your automation over to it. Conversion is part of the original workflow described in the file-based app announcement.
For small repository utilities, this is a comfortable progression: start with the operation you need, extract helpers when they make the code clearer, and reuse existing dependencies as needed. .NET 11 gives that little C# file considerably more room to grow.


