DEV Community

Cover image for Template Your Own Clean, Precise Boilerplate Code: No AI, No Wallet Drain. Part 3 – Enough Theory, Now We Code
Giorgi Kobaidze
Giorgi Kobaidze

Posted on

Template Your Own Clean, Precise Boilerplate Code: No AI, No Wallet Drain. Part 3 – Enough Theory, Now We Code

Introduction

This is the third and final part of the series. Unlike the first two, this one is fully hands-on. We'll be writing code, a lot of code, ourselves, no AI prompting and no vibe coding. Though You don't have to follow along step by step, even just reading and understanding what each part does will give you valuable insights. But if you do code along (which is highly recommended), the experience will be completely different, and you'll retain much more of what you learn. The choice is yours.

What to Expect in This Article

It's always fascinating to see how things are implemented, you get a glimpse into the engineers' thought process and a behind-the-scenes look at how everything works.

But here comes the kicker:

What if I told you that you could do the same yourself? What if I told you, you could create your own template project, so that the next time you start something new, you don't waste time setting up structure, files, and boilerplate code? Instead, you could dive straight into the application-specific logic. That's exactly what we're going to build today.

This article is going to be heavily technical, even more so than the previous two. It builds directly on what we've already covered, applying the knowledge from earlier installments. By the end, you'll know how to create custom project templates (both project and item) that you can reuse in your own work or share with your team.

Here's the plan:

  • Define the project structure and requirements for our custom template.
  • Break down the parts, components, and logic needed to build it.
  • Create the template completely from scratch, and by that I mean starting from a completely empty folder.

Before We Dive In: Quick Recap

As mentioned earlier, this part of the series builds on the previous two. The content across all three parts is closely interconnected.

⚠️Important

While it's recommended to review the first two parts before diving into this one, it's not strictly necessary. You can still follow along and complete all the steps shown here. Use the following guideline:

  • ⏱️If your goal is to quickly learn how to build custom templates and you're not concerned with the low-level details, you can skip the first two parts. Following along here will give you everything (at least all the essentials) you need, though you may miss some of the deeper explanations and important definitions.

  • ⚙️If you're curious about how everything works under the hood and want to understand Microsoft's design choices, be sure to check out the previous parts, they're definitely worth it!

I'll link them right here for your convenience:

Part 1: Microsoft’s Implementation

In the first part of the series, we reverse-engineered Microsoft's approach to creating templates and uncovered what happens under the hood.

Part 2: Exploring Every Hidden Trail

This part focused on identifying each component of the template project and understanding how it all works.

Defining the Project Scope

The main goal of this article is to mirror a real-life scenario as closely as possible. That means we won't just follow the happy path, instead, we'll introduce custom requirements you wouldn't normally encounter in a typical "how-to" tutorial or article.

Requirements

We need to set up a template repository that will generate a project meeting the following requirements:

Solution Folder Structure:

  1. Separate folders for each group of files:

src/, tests/, samples/.

  1. Basic README template. We'll also create a separate item template for the README file, you'll see why this is useful later in the article.

📂 src/ Folder

This folder contains the source code, organized into two projects: Company.App the main application, and Company.App.Engine a separate module referenced by the main app. You're free to arrange projects however you like, including creating subfolders for different purposes. The structure shown below is kept simple to avoid unnecessary complexity and focus only on what's relevant in this context.

Also note that we're not using concrete names here, nothing really screams "concrete" about Company or App. This is a template project, so you can replace these placeholders with anything you like. The naming convention and structure shown here is just one of the common patterns you can follow. If you prefer a different structure or want to expand it with additional parts, feel free to adapt it to your needs.

Inside this folder, we'll also include static front-end files that will generate a landing page. What you'll find there will be a nice surprise.

📂 tests/ Folder

This folder is optional. Now, before you flip the table, I KNOW, I KNOW... tests should never be optional, and I fully agree with that. But sometimes you just need to throw together a simple application to test something locally, without any real intention of writing unit tests. In that case, generating test projects doesn't make much sense.

So, by default, the tests/ folder (with its content) will be included, but developers will have the option to leave it out.

We'll have two separate test projects: one for unit tests and another for integration tests. In my experience, unit tests are used far more often than integration tests. Therefore, by default, only the unit test project will be generated. If a user explicitly requests integration tests as well, we'll include that project too.

📂 samples/ folder

This type of testing project is quite rare, I hardly ever use it. I usually only create it when developing a library to test its functionality immediately.

If your main project is a Web API, you can still make use of this manual test project: simply send HTTP requests to your endpoints and observe the results. The test application itself can be a simple console app, nothing too fancy.

Requirements Simplified

I know, these requirements are way too verbose, just like in real-life projects. Understanding them is rarely easy because clients or even your own team might not present the information in the way you understand the best, and thats completely natural - everyone has their own style of effectively learning information.

So let's make things digestible and simple: cut out the unnecessary details and focus on the "meat" part of the problem. This approach is generally a good practice, you should not only break down the problem but also make it easier and more accessible, so you can translate it into code smoothly.

You know what? Let's Roll Two Dice With One Throw 🎲🎲

Let's not only note everything in a simplified way, but do it while creating the folder structure we're going to build.

We're going to create a custom folder structure, one that makes our solution as flexible and scalable as possible.

I've actually written a separate article about the project structure I typically use for medium to large projects, and it has worked well so far. I'll embed the article here if you want to check out the full version.

Our example will be a simplified version of the structure described in the article, keeping it digestible and avoiding unnecessary complexity. We won't use everything from the full structure, but most of our example will be based on it.

Here’s the full folder and file structure:

📁 / (root)  
│   Root of the repository. Contains project files, CI/CD configs, metadata, and top-level documentation.
│
├── 📁 src/                          # The root folder of your application.
│   ├── 📁 Company.App/              # The main application project
│   │   ├── 📁 Controllers/          # .NET controllers
│   │   ├── 📁 wwwroot/              # Static web assets: JS, CSS, images, frontend build output
│   │   │   ├── 📄 index.html        # Main landing page
│   │   │   ├── 📁 css/
│   │   │   │   └── 📄style.css      # Website CSS rules
│   │   │   ├── 📁 js/
│   │   │   │   └── 📄script.js      # Website scripts
│   │   │   └── 📁 assets/
│   │   │       └── 📄icon.png       # Website favicon
│   │   ├── 📄 Program.cs            # Application entry point
│   │   ├── 📄 Company.App.csproj    # Project file
|   |   └── 📄 README.md             # README file for the project
│   └── 📁 Company.App.Engine/       # Additional module or a specific feature (optional, not included by default)
│       └── 📄 Company.App.Engine.csproj # Project file
│
├── 📁 tests/                        # Main tests project 
│   ├── 📁 UnitTests/                # Folder for the unit tests (optional, included by default)
│   │   ├── 📁 Company.App.UnitTests/ # Unit tests for the main application            
│   │   │   └── 📄 Company.App.UnitTests.csproj # Project file
│   │   └── 📁 Company.App.Engine.UnitTests/ # Unit tests for the additional module     
│   │       └── 📄 Company.App.Engine.UnitTests.csproj # Project file
│   │
│   ├── 📁 IntegrationTests/         # Folder for the integration tests (optional, not included by default)
│   │   ├── 📁 Company.App.IntegrationTests/ # Integration tests for the main application     
│   │   │   └── 📄 Company.App.IntegrationTests.csproj # Project file
│   │   └── 📁 Company.App.Engine.IntegrationTests/ # Integration tests for the additional module
│   │       └── 📄 Company.App.Engine.IntegrationTests.csproj # Project file
│
├── 📁 samples/                      # Lightweight apps for manual testing (optional, not included by default)
│   └── 📁 Company.App.Sample/       # Console application
│       ├── 📄 Program.cs            # Application entry point
│       └── 📄 Company.App.Sample.csproj # Project file
│
├── 📄 .gitignore                    # Ignore build artifacts, user secrets, etc.
├── 📄 .editorconfig                 # Configuration file to define and maintain consistent coding styles across the project.
├── 📄 README.md                     # Project overview and setup instructions
└── 📄 Company.App.sln               # Solution file
Enter fullscreen mode Exit fullscreen mode

ℹ️ Keep in mind: Some files are skipped in this structure, as they don't play a vital role in understanding the context.

Programmers, Start Your Terminals!

As mentioned at the start of the article, we'll build the entire project from scratch, starting from an empty folder. We'll use the most effective tools for the job: the CLI, terminal, and a code editor. You're free to choose whichever tools you prefer in each category.

Just so you know, my current setup is:

  • CLI: PowerShell
  • Terminal: Windows Terminal
  • Code Editor: Visual Studio Code

There's an article on this platform by a guy called Giorgi Kobaidze, that explains how to create a .NET solution from scratch using only the CLI. Be sure to check it out for more detailed information.

Now, open your terminal and navigate to the folder where you usually keep your repositories, or create a new folder and go there.

Setting Up Basic Files & Folders

⚠️Keep in mind: The commands in this article are written for PowerShell, so they may differ from Bash. However, the underlying concepts are the same in both.

Let's create the root folder first:

mkdir dotnet-templates
Enter fullscreen mode Exit fullscreen mode

Step into the folder:

cd .\dotnet-templates\
Enter fullscreen mode Exit fullscreen mode

Before we start creating folders and files for our project template, there are a few preliminary steps we need to complete in this location.

First, let's create a .csproj file. To simplify the process, we'll use VS Code. Open your root folder and create a new file named DotnetTemplates.csproj.

Code editor

You might be asking yourself, “What's that?”

This is a bit of a sneak peek of what's coming at the end of the article. Essentially, this .csproj file is for creating NuGet packages. We'll use it to build a NuGet package that anyone can download and use.

Copy and paste the following XML content to your .csproj file:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <PackageType>Template</PackageType>
        <PackageVersion>1.0.0</PackageVersion>
        <PackageId>Giorgi.Dotnet.Templates</PackageId>
        <Title>Giorgi's Dotnet Templates</Title>
        <Authors>Giorgi</Authors>
        <Description>Custom project templates created by Giorgi</Description>
        <PackageTags>dotnet-new;templates;</PackageTags>
        <TargetFramework>net9.0</TargetFramework>

        <IncludeContentInPack>true</IncludeContentInPack>
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <ContentTargetFolders>content</ContentTargetFolders>
        <NoDefaultExcludes>true</NoDefaultExcludes>
    </PropertyGroup>

    <ItemGroup>
        <Content Include="templates\**\*" Exclude="templates\**\bin\**;templates\**\obj\**" />
        <Compile Remove="**\*" />
    </ItemGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

⚠️Important: Make sure to use your own name instead of mine. (Unless your name is also Giorgi—in that case, wow, what a coincidence! Welcome to the elite club of people with, objectively, the best name on Earth.)

Jokes aside, if you're building this template for your company or team, you can use your company or team name instead.

Configuring Git

Before we continue, you'll likely want to upload this repository to your source control system (GitHub, GitLab, Bitbucket, etc.). To prepare for that, let's add a .gitignore file first. The easiest way is to use the built-in .NET item template. Open your CLI and run the following command:

dotnet new gitignore
Enter fullscreen mode Exit fullscreen mode

And just like that, a fresh .gitignore file will be added to your folder.

Now, let's initialize a new Git repository.

git init
Enter fullscreen mode Exit fullscreen mode

Creating the Main Template Folder

Alright, now let's create a new folder where our templates will be placed:

Code editor

This folder will contain all the custom templates you create, and you can add as many templates as you like.

Let's create a new folder for our project called web-app-template. Inside this folder, create another folder named .template.config. If you've read the previous parts of this article series, that name should sound familiar - it’s where the template.json file belongs. Go ahead and create the template.json file inside that folder.

.editorconfig and .gitignore Files

Let's add the .editorconfig and .gitignore files.

First, navigate to your web-app-template folder using CLI.

And type the following commands to create new .editorconfig and .gitignore files

dotnet new gitignore
dotnet new editorconfig
Enter fullscreen mode Exit fullscreen mode

Project Structure So Far

At this point, the project structure should look like this:

Code editor

The Three Essential Folders

Perfect. Now, following the structure we outlined earlier, let's create the src, test, and samples folders.

Stay in the current web-app-template folder and run the following command. Alternatively, you can create the folders using your code editor.

mkdir src, tests, samples
Enter fullscreen mode Exit fullscreen mode

This will create src, tests, and samples folders at the same time.

⚠️Note: Normally, when creating a solution, you would also add a README file. We'll skip that for now, since I don't want you to create it manually, I'll show you a better alternative shortly.

Creating the Project

Now, we're going to create a web application. We'll use Microsoft’s built-in template to generate a new project and then modify it to fit our requirements. While we could create everything manually, that would take much longer and go beyond the scope of this article.

When you create a template that generates certain files or code based on options passed to the dotnet new command, you typically need to include all possible folders, files, and code. Depending on the parameters provided, the template will then include or exclude the corresponding items.

Let's start with the solution file.

Our solution file should follow this convention: Company.App.sln or App.sln, where .sln is the file extension. The part before the extension can be anything, so we'll make it user-defined. To achieve this, we'll use a custom placeholder that will be replaced with the actual value later.

Pro tip: Use a unique and specific name for your placeholder. You don't want to accidentally replace something unintentionally. For example, generic names like WebApplication, template, or placeholder are a BAD idea, as they could unintentionally match and replace text in your source code or README.

To avoid the issues mentioned above, let's use a unique placeholder like CUSTOM_TEMPLATE_PLACEHOLDER when generating the solution file.

In CLI, stay in the current web-app-template folder and run the following:

dotnet new sln -n CUSTOM_TEMPLATE_PLACEHOLDER
Enter fullscreen mode Exit fullscreen mode

Now let's generate our project. Stay in the current web-app-template folder and run the following:

dotnet new webapi --use-controllers -o src/CUSTOM_TEMPLATE_PLACEHOLDER
Enter fullscreen mode Exit fullscreen mode

Let's quickly clean up a few things for simplicity. Since we won't be using OpenAPI here, we can remove the following lines:

From Program.cs:

Program class

From CUSTOM_TEMPLATE_PLACEHOLDER.csproj:

CUSTOM_TEMPLATE_PLACEHOLDER

Let's leave the rest as is for now and create the optional module. This module will be a class library.

Run the following command:

dotnet new classlib -o src/CUSTOM_TEMPLATE_PLACEHOLDER.Engine
Enter fullscreen mode Exit fullscreen mode

And with that, we're done creating our source code projects.

Creating Test Projects

Next, it's time to generate the test projects. We'll use xUnit for both unit and integration tests.

Let's start by creating folders for each project. You can do this either in your editor or via the CLI. I'll demonstrate using the CLI:

mkdir tests/UnitTests, tests/IntegrationTests
Enter fullscreen mode Exit fullscreen mode

Now, we'll create all the possible projects in their corresponding folders, starting with the unit tests. Run the following commands:

dotnet new xunit -o tests/UnitTests/CUSTOM_TEMPLATE_PLACEHOLDER.UnitTests
Enter fullscreen mode Exit fullscreen mode
dotnet new xunit -o tests/UnitTests/CUSTOM_TEMPLATE_PLACEHOLDER.Engine.UnitTests
Enter fullscreen mode Exit fullscreen mode

And now the integration tests, pretty much the same exact way:

dotnet new xunit -o tests/IntegrationTests/CUSTOM_TEMPLATE_PLACEHOLDER.IntegrationTests
Enter fullscreen mode Exit fullscreen mode
dotnet new xunit -o tests/IntegrationTests/CUSTOM_TEMPLATE_PLACEHOLDER.Engine.IntegrationTests
Enter fullscreen mode Exit fullscreen mode

That’s it! Our test projects are all set.

Sample Project

It's time to add the sample project to the samples folder. As mentioned earlier, it will be a simple console application used to quickly test components from the other projects.

Run the following command:

dotnet new console -o samples/CUSTOM_TEMPLATE_PLACEHOLDER.Sample
Enter fullscreen mode Exit fullscreen mode

Adding the Projects to the Solution

We've added all the projects we need.

But that's not it yet

We need to add them to the .sln file so that all the projects are part of a single solution.

To make it happen, use the following commands:

dotnet sln add src/CUSTOM_TEMPLATE_PLACEHOLDER/CUSTOM_TEMPLATE_PLACEHOLDER.csproj

dotnet sln add .\src\CUSTOM_TEMPLATE_PLACEHOLDER.Engine\CUSTOM_TEMPLATE_PLACEHOLDER.Engine.csproj

dotnet sln add .\tests\UnitTests\CUSTOM_TEMPLATE_PLACEHOLDER.UnitTests\CUSTOM_TEMPLATE_PLACEHOLDER.UnitTests.csproj

dotnet sln add .\tests\UnitTests\CUSTOM_TEMPLATE_PLACEHOLDER.Engine.UnitTests\CUSTOM_TEMPLATE_PLACEHOLDER.Engine.UnitTests.csproj

dotnet sln add .\tests\IntegrationTests\CUSTOM_TEMPLATE_PLACEHOLDER.IntegrationTests\CUSTOM_TEMPLATE_PLACEHOLDER.IntegrationTests.csproj

dotnet sln add .\tests\IntegrationTests\CUSTOM_TEMPLATE_PLACEHOLDER.Engine.IntegrationTests\CUSTOM_TEMPLATE_PLACEHOLDER.Engine.IntegrationTests.csproj

dotnet sln add .\samples\CUSTOM_TEMPLATE_PLACEHOLDER.Sample\CUSTOM_TEMPLATE_PLACEHOLDER.Sample.csproj
Enter fullscreen mode Exit fullscreen mode

After running these commands, you can verify that the project files have been added to the .sln file by opening it and checking its contents. It may not look very pretty, but in the next article, we'll discuss what's coming next from Microsoft.

Setting Up the Landing Page Front-End

At this point, the only remaining task is to add the front-end files.

This time, let's add the files directly from VS Code. First, create a wwwroot folder. Inside it, add the folders and files according to the structure we outlined earlier.

⚠️Important: These files are included in the GitHub repo, so just grab them from there instead of copying everything here - they're quite large.

GitHub logo georgekobaidze / dotnet-templates

A centralized repository for storing and maintaining template projects that provide boilerplate code. It serves as a quick starting point for new projects, ensuring consistency, reducing repetitive setup work, and promoting best practices.

With my content, you can play a simple F1 pitstop game. Share your results, and let’s see who’s the Max Verstappen of the DEV community! No cheating! Make sure to screenshot your first result to keep things fair.

This is what I got on my first attempt:

My result

Project Structure So Far

At this point, the project structure should look like this:

project structure

.template.json File

Alright, we're all set to start adding content to our .template.json file. To keep things concise and avoid duplication, I won't explain each parameter here. For a detailed explanation, please refer to part 2 of this series.

Let's begin with the essentials.

Here is the initial content of the template.json file:

{
    "$schema": "http://json.schemastore.org/template",
    "author": "Giorgi",
    "classifications": [
        "webapi"
    ],
    "identity": "Giorgi.Dotnet.Tempate.Webapi",
    "name": "Giorgi's custom Web API template",
    "description": "Template for generating custom Web API projects",
    "shortName": "fullsendapi",
    "tags": {
        "language": "C#",
        "type": "project"
    },
    "preferNameDirectory": true,
    "sourceName": "CUSTOM_TEMPLATE_PLACEHOLDER"
}
Enter fullscreen mode Exit fullscreen mode

Installing and Testing the Template

Our template isn't completed yet, but technically it's ready to be installed and tested. Let's intall it fist:

Stay in the current web-app-template folder and run the following command:

dotnet new install .
Enter fullscreen mode Exit fullscreen mode

You should see the following output:

output

If we generate the content using this JSON, it will include every folder and file in our template. As you can see, this isn't very useful, but you can still generate it. Let's just test it.

⚠️Critical: When generating a new project with dotnet new, make sure to navigate to a folder outside your template project. Otherwise, the new project will be created inside your template, making it messy. Keeping the template project clean is critical.

Once you've navigated to another folder, run the following command:

dotnet new fullsendapi -o CadillacF1Team.BrakeTemperatureMonitor
Enter fullscreen mode Exit fullscreen mode

If the project is generated successfully, you'll receive a confirmation message.

You can then open the newly generated project and inspect its files. Check out the screenshot (from JetBrains Rider)

Rider

SO FAR SO GOOD!

Customizing the Template

Now it's time to start customizing our template to include, exclude, or modify projects, files, and folders. The goal is to generate only the required items instead of everything.

We'll start from the highest level and gradually work our way down to the lower levels.

Samples:

As mentioned in the requirements, the samples folder should be optional and not included by default. Let's implement that.

The first step is to add a symbol to the template.json file.

A symbol in a .NET template's template.json file is a JSON object that defines a parameter or variable that can control the template’s behavior.

Check out the template.json file content below:

{
    "$schema": "http://json.schemastore.org/template",
    "author": "Giorgi",
    "classifications": [
        "webapi"
    ],
    "identity": "Giorgi.Dotnet.Tempate.Webapi",
    "name": "Giorgi's custom Web API template",
    "description": "Template for generating custom Web API projects",
    "shortName": "fullsendapi",
    "tags": {
        "language": "C#",
        "type": "project"
    },
    "preferNameDirectory": true,
    "sourceName": "CUSTOM_TEMPLATE_PLACEHOLDER",
    "symbols": {
        "includeSamples": {
            "type": "parameter",
            "datatype": "bool",
            "description": "Include a sample project for manual testing of the main application?",
            "defaultValue": "false"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And right after the "symbols" object, add a new object - "sources".

The source will ensure that the folder is excluded physically.

{
    "$schema": "http://json.schemastore.org/template",
    "author": "Giorgi",
    "classifications": [
        "webapi"
    ],
    "identity": "Giorgi.Dotnet.Tempate.Webapi",
    "name": "Giorgi's custom Web API template",
    "description": "Template for generating custom Web API projects",
    "shortName": "fullsendapi",
    "tags": {
        "language": "C#",
        "type": "project"
    },
    "preferNameDirectory": true,
    "sourceName": "CUSTOM_TEMPLATE_PLACEHOLDER",
    "symbols": {
        "includeSamples": {
            "type": "parameter",
            "datatype": "bool",
            "description": "Include a sample project for manual testing of the main application?",
            "defaultValue": "false"
        }
    },
    "sources": [
        {
            "source": "./",
            "target": "./",
            "modifiers": [
                {
                    "condition": "(!includeSamples)",
                    "exclude": [
                        "samples/**"
                    ]
                }
            ]
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

There's one more detail: we need to make sure the solution file doesn't reference the samples folder, so we'll modify it as well.

Navigate to the .sln file of your project and search for "samples". Remove everything associated with it.

To do this, use the following syntax:

#if(includeSamples)
...
#endif
Enter fullscreen mode Exit fullscreen mode

Anything between #if and #endif will be ignored if the includeSamples parameter is false. Check out the screenshot of the .sln file.

sln file

Tests

Now, let's do the same for the test projects. We need parameters for both unit tests and integration tests. Both are optional, but there's a key difference: unit tests are included by default, while integration tests are not.

We'll define two additional parameters in the symbols object we've already created: includeIntegrationTests and includeUnitTests.

Add these two objects to the "symbols":

"includeIntegrationTests": {
    "type": "parameter",
    "datatype": "bool",
    "description": "Include an integration tests project?",
    "defaultValue": "false"
}
Enter fullscreen mode Exit fullscreen mode
"includeUnitTests": {
    "type": "parameter",
    "datatype": "bool",
    "description": "Exclude the unit tests project?",
    "defaultValue": "true"
}
Enter fullscreen mode Exit fullscreen mode

Then add corresponding source modifiers, Note that you only need to add new modifiers.

{
    "condition": "(!includeIntegrationTests)",
    "exclude": [
        "tests/IntegrationTests/**"
    ]
}
Enter fullscreen mode Exit fullscreen mode
{
    "condition": "(!includeUnitTests)",
    "exclude": [
        "tests/UnitTests/**"
    ]
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll update the .sln file. Remove everything related to the integration tests if the includeIntegrationTests symbol is false, and do the same for the includeUnitTests symbol.

sln file

We're not done with the tests yet. If neither unit tests nor integration tests are included, we need to remove the entire tests folder. Let's add one more modifier to the sources object to handle this scenario:

{
    "condition": "(!includeUnitTests) && (!includeIntegrationTests)",
    "exclude": [
        "tests/**"
    ]
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we're checking both parameters. If they are both false, the tests folder is ignored entirely.

We should also make the corresponding changes to the .sln file.

sln file

Submodule

Now it's time to move on to the main projects. We want to make the "Engine" project optional, so we'll create another symbol called includeSubmodule. By default, this project will not be included.

"includeSubmodule": {
    "type": "parameter",
    "datatype": "bool",
    "description": "Include a sub-module?",
    "defaultValue": "false"
}
Enter fullscreen mode Exit fullscreen mode

Add a modifier for the Engine project just like we did for the other items. But there's one catch: if we don't include the submodule, there's no need to create its unit test project either.

We'll use the includeSubmodule symbol in the condition that determines whether the unit tests for this project are generated. Let's implement that now.

{
    "condition": "(!includeSubmodule)",
    "exclude": [
        "src/CUSTOM_TEMPLATE_PLACEHOLDER.Engine/**",
        "tests/CUSTOM_TEMPLATE_PLACEHOLDER.Engine.IntegrationTests/**",
        "tests/CUSTOM_TEMPLATE_PLACEHOLDER.Engine.UnitTests/**"
    ]
}
Enter fullscreen mode Exit fullscreen mode

We also need to modify our .sln file not to include the folder if symbol is set to false.

sln file

The Web Project

Now it's time to handle the main project, which can operate in two modes: with or without static files.

Let's update the project to support static files by adding these two lines:

Static file support

Add another symbol:

"useGameMode": {
    "type": "parameter",
    "datatype": "bool",
    "description": "The game mode will allow you to play an awesome F1 pitstop game",
    "defaultValue": "true"
}
Enter fullscreen mode Exit fullscreen mode

And a modifier to exclude the static files entirely in case it's false.

{
    "condition": "(!useGameMode)",
    "exclude": [
        "src/CUSTOM_TEMPLATE_PLACEHOLDER/wwwroot/**"
    ]
}
Enter fullscreen mode Exit fullscreen mode

And we also need to remove the methods responsible for enabling static files in case this symbol is set to false.

files

Configurable Framework Version

Let's now use another type of parameter to make the framework version of our framework version configurable.

First, let's define our parameter symbol (user-provided):

"framework": {
    "type": "parameter",
    "description": "The target framework of the project",
    "datatype": "choice",
    "choices": [
        {
            "choice": "net8.0"
        },
        {
            "choice": "net9.0"
        }
    ],
    "defaultValue": "net9.0",
    "replaces": "DOTNET_TARGET_FRAMEWORK"
}
Enter fullscreen mode Exit fullscreen mode

Why we might need this?

As you know, .NET 8 is an LTS version. Some companies prefer using LTS versions rather than the latest releases, so this configuration can be useful when working in a team that uses .NET 8 but still wants to experiment with .NET 9.

Now, let's update all places where the .NET version is specified in the .csproj files.

Open each .csproj file in your solution and replace the <TargetFramework>net9.0</TargetFramework> value with the corresponding placeholder.

Let's see one example for the CUSTOM_TEMPLATE_PLACEHOLDER.Sample.csproj file:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>DOTNET_TARGET_FRAMEWORK</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode

Do exactly the same in all the other .csproj files as well.

Let's Take a Look at The CLI

First things first, let's check if our template is actually in the list.

Run the following command:

dotnet new list
Enter fullscreen mode Exit fullscreen mode

cli

Now let's check the details. To dive into the interface and all the details, run the following command:

dotnet new fullsendapi --help
Enter fullscreen mode Exit fullscreen mode

powershell

See how awesome it is?

Now, let's generate an application that includes the additional helper module, enables game mode, and also adds the integration tests. We'll use .NET 8 as the framework version.

Run the following command:

dotnet new fullsendapi --includeSubmodule --includeIntegrationTests --framework net8.0 -o CadillacF1Team.TyreTemperatureMonitor
Enter fullscreen mode Exit fullscreen mode

And let's check the generated project:

generated project

As you can see on the screenshot, every component is present. And we didn't even need to specify the default values.

Now, let's open the csproj files and check the framework version:

csproj file

It's .NET 8, as requested.

Our template is now fully functional.

README Template

As you can see, everything's pretty much ready, except for one thing. Something that should be included in every project. You guessed it: a README file.

Sure, we could manually add a README to every project and copy the structure over and over again. But let's be honest, that's not the most convenient approach. So instead, why don't we create a new item template for README files?

Here's the plan:

  • Create a new template for README files.

  • This template will contain predefined content, including the symbol placeholders we've already used (plus some new ones). That way, whenever a project is created, its README will already be filled with relevant information pulled directly from the project itself.

  • Generate README files in multiple places within the template project.

Creating the Template

Since we're making a template for a single file (and not even a C# file), we'll use a different template type: an item template.

Under the templates folder, create a new folder called readme-template. Inside it, add the .template.config folder with a template.json file, just like we did earlier for the web app template.

template

In the template.json file, add the following:

{
  "$schema": "http://json.schemastore.org/template",
  "author": "Giorgi",
  "classifications": [ "README", "Documentation" ],
  "identity": "Giorgi.Tempate.Readme",
  "name": "README file",
  "shortName": "fullsendreadme",
  "tags": {
    "type": "item"
  }
}
Enter fullscreen mode Exit fullscreen mode

We could add symbols here to let users specify additional parameters, but in this case, we don't really need them, so let's keep it simple and leave it as is.

Now, create a new README.md file inside the readme-template folder and add the following content:

# Project Name

A brief description

## Technologies
**Framework:** .NET 9

## Repository
**URL:** https://github.com/[user_name]/[repo_name]

## Documentation

## Features

## Installation

## Dependencies

## Other
Enter fullscreen mode Exit fullscreen mode

Let's not get too carried away with the README content, we just need a simple example for now. You can always expand it later (or even contribute more content to this repository with pull requests).

Think about it: we already know the project name and the framework. So instead of hardcoding those values, let's use placeholders. That way, when the template engine creates a project with this README, it will automatically replace them with the correct values.

There's one catch, though: the project name and framework aren't provided in a "human-readable" format. And we definitely don"t want our README looking awkward, right?

For example, if we run the following command:

dotnet new fullsendapi --framework net9.0 -o CadillacF1.BrakeTemperatureMonitor
Enter fullscreen mode Exit fullscreen mode

It will result in the following values:

sourceName: CadillacF1.BrakeTemperatureMonitor
framework: net9.0

If we simply drop those values into the README, one as a title and the other as the framework, it's going to look pretty rough. A README should be clean and professional.

So, let's go the extra mile: we'll generate two additional symbols that hold the “human-readable” versions of those values.

Now, you might be thinking: "Oh no, not more #if ... #endif blocks!" Don't worry—you won"t need them here. Why? Because we can actually define generated symbols. These aren't provided by the user directly; instead, they're automatically derived from the user-provided symbols.

So what's our goal?

We need to convert:

net9.0 to .NET 9.0
(or net8.0 to .NET 8.0)

and

CadillacF1.BrakeTemperatureMonitor to Cadillac F1 - Brake Temperature Monitor

Let's use generated symbols for this job. We're adding this to the web-app-template symbols:

"plainTextName": {
    "type": "generated",
    "generator": "regex",
    "datatype": "string",
    "replaces": "PLAIN_TEXT_NAME",
    "parameters": {
        "source": "name",
        "steps": [
            {
                "regex": "(?<=[a-z0-9])(?=[A-Z])",
                "replacement": " "
            },
            {
                "regex": "\\.",
                "replacement": " - "
            }
        ]
    }
},
"dotNetVersionNumber": {
    "type": "generated",
    "generator": "regex",
    "datatype": "string",
    "replaces": "DOTNET_TEMPLATE_VERSION_NUMBER",
    "parameters": {
        "source": "framework",
        "steps": [
            {
                "regex": "^net(\\d+)\\.(\\d+)$",
                "replacement": ".NET $1.$2"
            }
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Additionally, we can go as far as using derived symbols to generate different formats of any text.

So, in your example: if your convention is to have repository names in kebab-case, you can simply define a derived symbol that takes the project name and converts it.

To achieve that, the first thing we need to do is to add a new object at the level of sources and symbols, called forms. Let's add it right between the two.

"forms": {
    "kebabCase": {
        "identifier": "kebabCase"
    }
}
Enter fullscreen mode Exit fullscreen mode

And now use it to declare a new symbol:

"kebabCasedName": {
    "type": "derived",
    "valueSource": "name",
    "replaces": "REPO_NAME_KEBAB_CASE",
    "valueTransform": "kebabCase"
}
Enter fullscreen mode Exit fullscreen mode

All we need to do is to use the placeholder in the README file.

# Project Name

A brief description

## Technologies
**Framework:** .NET 9

## Repository
**URL:** https://github.com/georgekobaidze/REPO_NAME_KEBAB_CASE

## Documentation

## Features

## Installation

## Dependencies

## Other
Enter fullscreen mode Exit fullscreen mode

So the next time you generate a new project with this readme file, the project name and the framework version will automatically be filled out.

In fact, you can still use this README file in other scenarios as well. You'll just have to manually replace the placeholders.

OK, let's install our new readme template. Navigate to the template folder using CLI and type:

dotnet new install .
Enter fullscreen mode Exit fullscreen mode

Now, let's generate a README file in the main project using dotnet new command. Navigate to the project folder and run the following command:

dotnet new fullsendreadme
Enter fullscreen mode Exit fullscreen mode

The new README file will appear in you solution folder. Make sure to check its content.

Now, let's generate our project again by running the following command (don't forget to navigate to the folder, where you generate your actual projects):

dotnet new fullsendapi -o CadillacF1Team.BrakeTemperatureMonitor
Enter fullscreen mode Exit fullscreen mode

And you'll see the generated README file with the proper content.

readme file

And with that, our template is now complete. 🚀

Packaging the Template for NuGet

OK, we're done with the main part. The last step is to publish our templates as NuGet packages so others can download and use them. Don't worry, it's extremely simple.

First, register at https://www.nuget.org/ and verify your account.

Next, go back to your terminal, navigate to the root folder of your template project (where the NuGet .csproj file is located), and run:

dotnet pack -c Release
Enter fullscreen mode Exit fullscreen mode

You'll find your NuGet package in the bin/Release/ folder. In my case, it's called Giorgi.Dotnet.Templates.1.0.0

Let's push our package to NuGet:

dotnet nuget push bin/Release/Giorgi.Dotnet.Templates.1.0.0.nupkg --api-key YOUR_API_KEY --source https://api.nuget.org/v3/index.json
Enter fullscreen mode Exit fullscreen mode

Let's find this package at NuGet.org:

nuget

As shown in the screenshot, to install this package, simply run the following command:

dotnet new install Giorgi.Dotnet.Templates::1.0.0
Enter fullscreen mode Exit fullscreen mode

Summary

Goes Without Saying

This article turned out a bit longer than usual, it's by far the biggest one so far. The goal was to provide as comprehensive an explanation as possible. Of course, it's not definitive, there’s always room to expand and improve.

Feel Free to Contribute

That said, you can build on what's already here. The possibilities are endless! Fork the repository, submit pull requests, start discussions - it’s your turn to contribute.

I'll be back with massive announcements. Stay tuned!

Top comments (0)