├── .config
└── dotnet-tools.json
├── .editorconfig
├── .gitattributes
├── .gitignore
├── .idea
└── .idea.WebApiTemplate
│ └── .idea
│ ├── .name
│ ├── encodings.xml
│ ├── indexLayout.xml
│ ├── projectSettingsUpdater.xml
│ └── vcs.xml
├── .pre-commit-config.yaml
├── .talismanrc
├── Directory.Build.props
├── Directory.Packages.props
├── LICENSE
├── README.md
├── WebApiTemplate.sln
├── src
├── Application
│ ├── Application.csproj
│ ├── Customers
│ │ ├── Commands
│ │ │ ├── CreateCustomerCommand.cs
│ │ │ ├── CreateCustomerCommandHandler.cs
│ │ │ ├── CreateCustomerCommandValidator.cs
│ │ │ ├── CustomerCommandHandlerBase.cs
│ │ │ ├── DeleteCustomerCommand.cs
│ │ │ ├── DeleteCustomerCommandHandler.cs
│ │ │ ├── DeleteCustomerCommandValidator.cs
│ │ │ ├── UpdateCustomerCommand.cs
│ │ │ ├── UpdateCustomerCommandHandler.cs
│ │ │ └── UpdateCustomerCommandValidator.cs
│ │ └── Queries
│ │ │ ├── GetCustomerByIdQuery.cs
│ │ │ ├── GetCustomerByIdQueryHandler.cs
│ │ │ └── GetCustomerByIdQueryValidator.cs
│ ├── Logging
│ │ ├── BaseLoggingDecorator.cs
│ │ ├── CommandHandlerLoggingDecorator.cs
│ │ └── QueryHandlerLoggingDecorator.cs
│ ├── QueryHandlerCachingDecorator.cs
│ └── Validation
│ │ ├── BaseValidationDecorator.cs
│ │ ├── CommandHandlerValidationDecorator.cs
│ │ └── QueryHandlerValidationDecorator.cs
├── Core
│ ├── BaseEntity.cs
│ ├── Core.csproj
│ ├── Customers
│ │ ├── Customer.cs
│ │ ├── ICustomerReadRepository.cs
│ │ └── ICustomerWriteRepository.cs
│ ├── IReadRepository.cs
│ ├── IUnitOfWork.cs
│ ├── IUnitOfWorkFactory.cs
│ ├── IWriteRepository.cs
│ └── Nothing.cs
├── Directory.Build.props
├── Directory.Packages.props
├── Infrastructure
│ ├── Customers
│ │ ├── CustomerReadRepository.cs
│ │ └── CustomerWriteRepository.cs
│ ├── Infrastructure.csproj
│ └── Persistence
│ │ ├── AppDbContext.cs
│ │ ├── ReadRepositoryBase.cs
│ │ ├── RepositoryOptions.cs
│ │ ├── UnitOfWork.cs
│ │ ├── UnitOfWorkFactory.cs
│ │ └── WriteRepositoryBase.cs
└── WebApi
│ ├── AppControllerBase.cs
│ ├── Customers
│ ├── CustomersController.cs
│ ├── Requests
│ │ ├── CreateCustomerRequest.cs
│ │ └── UpdateCustomerRequest.cs
│ └── Responses
│ │ └── CustomerCreatedResponse.cs
│ ├── Dockerfile
│ ├── Program.cs
│ ├── Properties
│ └── launchSettings.json
│ ├── WebApi.csproj
│ ├── appsettings.Development.json
│ └── appsettings.json
└── tests
├── Directory.Build.props
├── Directory.Packages.props
├── IntegrationTests
├── AppTestCollection.cs
├── AppWebApplicationFactory.cs
├── BaseTestClass.cs
├── Constants.cs
├── Customers
│ └── Controllers
│ │ ├── CreateTests.cs
│ │ ├── DeleteTests.cs
│ │ └── GetByIdTests.cs
├── IntegrationTests.csproj
└── xunit.runner.json
└── UnitTests
├── Customers
├── CreateCustomerCommandHandler
│ └── HandleTests.cs
└── GetCustomerByIdQueryHandler
│ └── HandleTests.cs
├── UnitTests.csproj
└── xunit.runner.json
/.config/dotnet-tools.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "isRoot": true,
4 | "tools": {
5 | "csharpier": {
6 | "version": "0.27.3",
7 | "commands": [
8 | "dotnet-csharpier"
9 | ]
10 | },
11 | "dotnet-ef": {
12 | "version": "8.0.1",
13 | "commands": [
14 | "dotnet-ef"
15 | ]
16 | }
17 | }
18 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; EditorConfig to support per-solution formatting.
2 | ; Use the EditorConfig VS add-in to make this work.
3 | ; http://editorconfig.org/
4 | ;
5 | ; Here are some resources for what's supported for .NET/C#
6 | ; https://kent-boogaart.com/blog/editorconfig-reference-for-c-developers
7 | ; https://learn.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference
8 | ;
9 | ; Be **careful** editing this because some of the rules don't support adding a severity level
10 | ; For instance if you change to `dotnet_sort_system_directives_first = true:warning` (adding `:warning`)
11 | ; then the rule will be silently ignored.
12 |
13 | ; This is the default for the codeline.
14 | root = true
15 |
16 | [*]
17 | indent_style = space
18 | charset = utf-8
19 | end_of_line = lf
20 | trim_trailing_whitespace = true
21 | insert_final_newline = true
22 |
23 | [*.cs]
24 | indent_size = 4
25 | dotnet_sort_system_directives_first = true
26 |
27 | # Don't use this. qualifier
28 | dotnet_style_qualification_for_field = false:suggestion
29 | dotnet_style_qualification_for_property = false:suggestion
30 |
31 | # use int x = .. over Int32
32 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
33 |
34 | # use int.MaxValue over Int32.MaxValue
35 | dotnet_style_predefined_type_for_member_access = true:suggestion
36 |
37 | # Require var all the time.
38 | csharp_style_var_for_built_in_types = true:suggestion
39 | csharp_style_var_when_type_is_apparent = true:suggestion
40 | csharp_style_var_elsewhere = true:suggestion
41 |
42 | # Disallow throw expressions.
43 | csharp_style_throw_expression = false:suggestion
44 |
45 | # Newline settings
46 | csharp_new_line_before_open_brace = all
47 | csharp_new_line_before_else = true
48 | csharp_new_line_before_catch = true
49 | csharp_new_line_before_finally = true
50 | csharp_new_line_before_members_in_object_initializers = true
51 | csharp_new_line_before_members_in_anonymous_types = true
52 |
53 | # Namespace settings
54 | csharp_style_namespace_declarations = file_scoped
55 |
56 | # Brace settings
57 | csharp_prefer_braces = true # Prefer curly braces even for one line of code
58 |
59 | [*.{xml,config,*proj,nuspec,props,resx,targets,yml,yaml,tasks}]
60 | indent_size = 2
61 |
62 | # Xml config files
63 | [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
64 | indent_size = 2
65 |
66 | [*.json]
67 | indent_size = 2
68 |
69 | [*.{ps1,psm1}]
70 | indent_size = 4
71 |
72 | [*.sh]
73 | indent_size = 4
74 |
75 | [*.{sln,cmd,bat}]
76 | end_of_line = crlf
77 |
78 | [*.{cs,vb}]
79 |
80 | # SYSLIB1054: Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time
81 | dotnet_diagnostic.SYSLIB1054.severity = warning
82 |
83 | # CA1018: Mark attributes with AttributeUsageAttribute
84 | dotnet_diagnostic.CA1018.severity = warning
85 |
86 | # CA1047: Do not declare protected member in sealed type
87 | dotnet_diagnostic.CA1047.severity = warning
88 |
89 | # CA1305: Specify IFormatProvider
90 | dotnet_diagnostic.CA1305.severity = warning
91 |
92 | # CA1507: Use nameof to express symbol names
93 | dotnet_diagnostic.CA1507.severity = warning
94 |
95 | # CA1510: Use ArgumentNullException throw helper
96 | dotnet_diagnostic.CA1510.severity = warning
97 |
98 | # CA1511: Use ArgumentException throw helper
99 | dotnet_diagnostic.CA1511.severity = warning
100 |
101 | # CA1512: Use ArgumentOutOfRangeException throw helper
102 | dotnet_diagnostic.CA1512.severity = warning
103 |
104 | # CA1513: Use ObjectDisposedException throw helper
105 | dotnet_diagnostic.CA1513.severity = warning
106 |
107 | # CA1725: Parameter names should match base declaration
108 | dotnet_diagnostic.CA1725.severity = suggestion
109 |
110 | # CA1802: Use literals where appropriate
111 | dotnet_diagnostic.CA1802.severity = warning
112 |
113 | # CA1805: Do not initialize unnecessarily
114 | dotnet_diagnostic.CA1805.severity = warning
115 |
116 | # CA1810: Do not initialize unnecessarily
117 | dotnet_diagnostic.CA1810.severity = warning
118 |
119 | # CA1821: Remove empty Finalizers
120 | dotnet_diagnostic.CA1821.severity = warning
121 |
122 | # CA1822: Make member static
123 | dotnet_diagnostic.CA1822.severity = warning
124 | dotnet_code_quality.CA1822.api_surface = private, internal
125 |
126 | # CA1823: Avoid unused private fields
127 | dotnet_diagnostic.CA1823.severity = warning
128 |
129 | # CA1825: Avoid zero-length array allocations
130 | dotnet_diagnostic.CA1825.severity = warning
131 |
132 | # CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly
133 | dotnet_diagnostic.CA1826.severity = warning
134 |
135 | # CA1827: Do not use Count() or LongCount() when Any() can be used
136 | dotnet_diagnostic.CA1827.severity = warning
137 |
138 | # CA1828: Do not use CountAsync() or LongCountAsync() when AnyAsync() can be used
139 | dotnet_diagnostic.CA1828.severity = warning
140 |
141 | # CA1829: Use Length/Count property instead of Count() when available
142 | dotnet_diagnostic.CA1829.severity = warning
143 |
144 | # CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder
145 | dotnet_diagnostic.CA1830.severity = warning
146 |
147 | # CA1831: Use AsSpan or AsMemory instead of Range-based indexers when appropriate
148 | # CA1832: Use AsSpan or AsMemory instead of Range-based indexers when appropriate
149 | # CA1833: Use AsSpan or AsMemory instead of Range-based indexers when appropriate
150 | dotnet_diagnostic.CA1831.severity = warning
151 | dotnet_diagnostic.CA1832.severity = warning
152 | dotnet_diagnostic.CA1833.severity = warning
153 |
154 | # CA1834: Consider using 'StringBuilder.Append(char)' when applicable
155 | dotnet_diagnostic.CA1834.severity = warning
156 |
157 | # CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync'
158 | dotnet_diagnostic.CA1835.severity = warning
159 |
160 | # CA1836: Prefer IsEmpty over Count
161 | dotnet_diagnostic.CA1836.severity = warning
162 |
163 | # CA1837: Use 'Environment.ProcessId'
164 | dotnet_diagnostic.CA1837.severity = warning
165 |
166 | # CA1838: Avoid 'StringBuilder' parameters for P/Invokes
167 | dotnet_diagnostic.CA1838.severity = warning
168 |
169 | # CA1839: Use 'Environment.ProcessPath'
170 | dotnet_diagnostic.CA1839.severity = warning
171 |
172 | # CA1840: Use 'Environment.CurrentManagedThreadId'
173 | dotnet_diagnostic.CA1840.severity = warning
174 |
175 | # CA1841: Prefer Dictionary.Contains methods
176 | dotnet_diagnostic.CA1841.severity = warning
177 |
178 | # CA1842: Do not use 'WhenAll' with a single task
179 | dotnet_diagnostic.CA1842.severity = warning
180 |
181 | # CA1843: Do not use 'WaitAll' with a single task
182 | dotnet_diagnostic.CA1843.severity = warning
183 |
184 | # CA1844: Provide memory-based overrides of async methods when subclassing 'Stream'
185 | dotnet_diagnostic.CA1844.severity = warning
186 |
187 | # CA1845: Use span-based 'string.Concat'
188 | dotnet_diagnostic.CA1845.severity = warning
189 |
190 | # CA1846: Prefer AsSpan over Substring
191 | dotnet_diagnostic.CA1846.severity = warning
192 |
193 | # CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters
194 | dotnet_diagnostic.CA1847.severity = warning
195 |
196 | # CA1852: Seal internal types
197 | dotnet_diagnostic.CA1852.severity = warning
198 |
199 | # CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method
200 | dotnet_diagnostic.CA1854.severity = warning
201 |
202 | # CA1855: Prefer 'Clear' over 'Fill'
203 | dotnet_diagnostic.CA1855.severity = warning
204 |
205 | # CA1856: Incorrect usage of ConstantExpected attribute
206 | dotnet_diagnostic.CA1856.severity = error
207 |
208 | # CA1857: A constant is expected for the parameter
209 | dotnet_diagnostic.CA1857.severity = warning
210 |
211 | # CA1858: Use 'StartsWith' instead of 'IndexOf'
212 | dotnet_diagnostic.CA1858.severity = warning
213 |
214 | # CA2008: Do not create tasks without passing a TaskScheduler
215 | dotnet_diagnostic.CA2008.severity = warning
216 |
217 | # CA2009: Do not call ToImmutableCollection on an ImmutableCollection value
218 | dotnet_diagnostic.CA2009.severity = warning
219 |
220 | # CA2011: Avoid infinite recursion
221 | dotnet_diagnostic.CA2011.severity = warning
222 |
223 | # CA2012: Use ValueTask correctly
224 | dotnet_diagnostic.CA2012.severity = warning
225 |
226 | # CA2013: Do not use ReferenceEquals with value types
227 | dotnet_diagnostic.CA2013.severity = warning
228 |
229 | # CA2014: Do not use stackalloc in loops.
230 | dotnet_diagnostic.CA2014.severity = warning
231 |
232 | # CA2016: Forward the 'CancellationToken' parameter to methods that take one
233 | dotnet_diagnostic.CA2016.severity = warning
234 |
235 | # CA2200: Rethrow to preserve stack details
236 | dotnet_diagnostic.CA2200.severity = warning
237 |
238 | # CA2208: Instantiate argument exceptions correctly
239 | dotnet_diagnostic.CA2208.severity = warning
240 |
241 | # CA2245: Do not assign a property to itself
242 | dotnet_diagnostic.CA2245.severity = warning
243 |
244 | # CA2246: Assigning symbol and its member in the same statement
245 | dotnet_diagnostic.CA2246.severity = warning
246 |
247 | # CA2249: Use string.Contains instead of string.IndexOf to improve readability.
248 | dotnet_diagnostic.CA2249.severity = warning
249 |
250 | # IDE0005: Remove unnecessary usings
251 | dotnet_diagnostic.IDE0005.severity = warning
252 |
253 | # IDE0011: Curly braces to surround blocks of code
254 | dotnet_diagnostic.IDE0011.severity = warning
255 |
256 | # IDE0020: Use pattern matching to avoid is check followed by a cast (with variable)
257 | dotnet_diagnostic.IDE0020.severity = warning
258 |
259 | # IDE0029: Use coalesce expression (non-nullable types)
260 | dotnet_diagnostic.IDE0029.severity = warning
261 |
262 | # IDE0030: Use coalesce expression (nullable types)
263 | dotnet_diagnostic.IDE0030.severity = warning
264 |
265 | # IDE0031: Use null propagation
266 | dotnet_diagnostic.IDE0031.severity = warning
267 |
268 | # IDE0035: Remove unreachable code
269 | dotnet_diagnostic.IDE0035.severity = warning
270 |
271 | # IDE0036: Order modifiers
272 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion
273 | dotnet_diagnostic.IDE0036.severity = warning
274 |
275 | # IDE0038: Use pattern matching to avoid is check followed by a cast (without variable)
276 | dotnet_diagnostic.IDE0038.severity = warning
277 |
278 | # IDE0043: Format string contains invalid placeholder
279 | dotnet_diagnostic.IDE0043.severity = warning
280 |
281 | # IDE0044: Make field readonly
282 | dotnet_diagnostic.IDE0044.severity = warning
283 |
284 | # IDE0051: Remove unused private members
285 | dotnet_diagnostic.IDE0051.severity = warning
286 |
287 | # IDE0055: All formatting rules
288 | dotnet_diagnostic.IDE0055.severity = suggestion
289 |
290 | # IDE0059: Unnecessary assignment to a value
291 | dotnet_diagnostic.IDE0059.severity = warning
292 |
293 | # IDE0060: Remove unused parameter
294 | dotnet_code_quality_unused_parameters = non_public
295 | dotnet_diagnostic.IDE0060.severity = warning
296 |
297 | # IDE0062: Make local function static
298 | dotnet_diagnostic.IDE0062.severity = warning
299 |
300 | # IDE0161: Convert to file-scoped namespace
301 | dotnet_diagnostic.IDE0161.severity = warning
302 |
303 | # IDE0200: Lambda expression can be removed
304 | dotnet_diagnostic.IDE0200.severity = warning
305 |
306 | # IDE2000: Disallow multiple blank lines
307 | dotnet_style_allow_multiple_blank_lines_experimental = false
308 | dotnet_diagnostic.IDE2000.severity = warning
309 |
310 | [{eng/tools/**.cs,**/{test,testassets,samples,Samples,perf,scripts,stress}/**.cs}]
311 | # CA1018: Mark attributes with AttributeUsageAttribute
312 | dotnet_diagnostic.CA1018.severity = suggestion
313 | # CA1507: Use nameof to express symbol names
314 | dotnet_diagnostic.CA1507.severity = suggestion
315 | # CA1510: Use ArgumentNullException throw helper
316 | dotnet_diagnostic.CA1510.severity = suggestion
317 | # CA1511: Use ArgumentException throw helper
318 | dotnet_diagnostic.CA1511.severity = suggestion
319 | # CA1512: Use ArgumentOutOfRangeException throw helper
320 | dotnet_diagnostic.CA1512.severity = suggestion
321 | # CA1513: Use ObjectDisposedException throw helper
322 | dotnet_diagnostic.CA1513.severity = suggestion
323 | # CA1802: Use literals where appropriate
324 | dotnet_diagnostic.CA1802.severity = suggestion
325 | # CA1805: Do not initialize unnecessarily
326 | dotnet_diagnostic.CA1805.severity = suggestion
327 | # CA1810: Do not initialize unnecessarily
328 | dotnet_diagnostic.CA1810.severity = suggestion
329 | # CA1822: Make member static
330 | dotnet_diagnostic.CA1822.severity = suggestion
331 | # CA1823: Avoid zero-length array allocations
332 | dotnet_diagnostic.CA1825.severity = suggestion
333 | # CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly
334 | dotnet_diagnostic.CA1826.severity = suggestion
335 | # CA1827: Do not use Count() or LongCount() when Any() can be used
336 | dotnet_diagnostic.CA1827.severity = suggestion
337 | # CA1829: Use Length/Count property instead of Count() when available
338 | dotnet_diagnostic.CA1829.severity = suggestion
339 | # CA1834: Consider using 'StringBuilder.Append(char)' when applicable
340 | dotnet_diagnostic.CA1834.severity = suggestion
341 | # CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync'
342 | dotnet_diagnostic.CA1835.severity = suggestion
343 | # CA1837: Use 'Environment.ProcessId'
344 | dotnet_diagnostic.CA1837.severity = suggestion
345 | # CA1838: Avoid 'StringBuilder' parameters for P/Invokes
346 | dotnet_diagnostic.CA1838.severity = suggestion
347 | # CA1841: Prefer Dictionary.Contains methods
348 | dotnet_diagnostic.CA1841.severity = suggestion
349 | # CA1844: Provide memory-based overrides of async methods when subclassing 'Stream'
350 | dotnet_diagnostic.CA1844.severity = suggestion
351 | # CA1845: Use span-based 'string.Concat'
352 | dotnet_diagnostic.CA1845.severity = suggestion
353 | # CA1846: Prefer AsSpan over Substring
354 | dotnet_diagnostic.CA1846.severity = suggestion
355 | # CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters
356 | dotnet_diagnostic.CA1847.severity = suggestion
357 | # CA1852: Seal internal types
358 | dotnet_diagnostic.CA1852.severity = suggestion
359 | # CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method
360 | dotnet_diagnostic.CA1854.severity = suggestion
361 | # CA1855: Prefer 'Clear' over 'Fill'
362 | dotnet_diagnostic.CA1855.severity = suggestion
363 | # CA1856: Incorrect usage of ConstantExpected attribute
364 | dotnet_diagnostic.CA1856.severity = suggestion
365 | # CA1857: A constant is expected for the parameter
366 | dotnet_diagnostic.CA1857.severity = suggestion
367 | # CA1858: Use 'StartsWith' instead of 'IndexOf'
368 | dotnet_diagnostic.CA1858.severity = suggestion
369 | # CA2007: Consider calling ConfigureAwait on the awaited task
370 | dotnet_diagnostic.CA2007.severity = suggestion
371 | # CA2008: Do not create tasks without passing a TaskScheduler
372 | dotnet_diagnostic.CA2008.severity = suggestion
373 | # CA2012: Use ValueTask correctly
374 | dotnet_diagnostic.CA2012.severity = suggestion
375 | # CA2249: Use string.Contains instead of string.IndexOf to improve readability.
376 | dotnet_diagnostic.CA2249.severity = suggestion
377 | # IDE0005: Remove unnecessary usings
378 | dotnet_diagnostic.IDE0005.severity = suggestion
379 | # IDE0020: Use pattern matching to avoid is check followed by a cast (with variable)
380 | dotnet_diagnostic.IDE0020.severity = suggestion
381 | # IDE0029: Use coalesce expression (non-nullable types)
382 | dotnet_diagnostic.IDE0029.severity = suggestion
383 | # IDE0030: Use coalesce expression (nullable types)
384 | dotnet_diagnostic.IDE0030.severity = suggestion
385 | # IDE0031: Use null propagation
386 | dotnet_diagnostic.IDE0031.severity = suggestion
387 | # IDE0038: Use pattern matching to avoid is check followed by a cast (without variable)
388 | dotnet_diagnostic.IDE0038.severity = suggestion
389 | # IDE0044: Make field readonly
390 | dotnet_diagnostic.IDE0044.severity = suggestion
391 | # IDE0051: Remove unused private members
392 | dotnet_diagnostic.IDE0051.severity = suggestion
393 | # IDE0059: Unnecessary assignment to a value
394 | dotnet_diagnostic.IDE0059.severity = suggestion
395 | # IDE0060: Remove unused parameters
396 | dotnet_diagnostic.IDE0060.severity = suggestion
397 | # IDE0062: Make local function static
398 | dotnet_diagnostic.IDE0062.severity = suggestion
399 | # IDE0200: Lambda expression can be removed
400 | dotnet_diagnostic.IDE0200.severity = suggestion
401 |
402 | # CA2016: Forward the 'CancellationToken' parameter to methods that take one
403 | dotnet_diagnostic.CA2016.severity = suggestion
404 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | ###############################
2 | # Git Line Endings #
3 | ###############################
4 |
5 | # Set default behaviour to automatically normalize line endings.
6 | * text=auto eol=lf
7 |
8 | # Force batch scripts to always use CRLF line endings so that if a repo is accessed
9 | # in Windows via a file share from Linux, the scripts will work.
10 | *.{cmd,[cC][mM][dD]} text eol=crlf
11 | *.{bat,[bB][aA][tT]} text eol=crlf
12 |
13 | # Force bash scripts to always use LF line endings so that if a repo is accessed
14 | # in Unix via a file share from Windows, the scripts will work.
15 | *.sh text eol=lf
16 |
17 | *.jpg binary
18 | *.png binary
19 | *.gif binary
20 |
21 | *.cs text=auto diff=csharp
22 |
23 | *.csproj text=auto
24 | *.vbproj text=auto
25 | *.fsproj text=auto
26 | *.dbproj text=auto
27 | *.sln text=auto eol=crlf
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # visualstudio,visualstudiocode,rider,dotnetcore,linux,windows,macos
2 |
3 | ### DotnetCore ###
4 | # .NET Core build folders
5 | bin/
6 | obj/
7 |
8 | # Common node modules locations
9 | /node_modules
10 | /wwwroot/node_modules
11 |
12 | ### Linux ###
13 | *~
14 |
15 | # temporary files which can be created if a process still has a handle open of a deleted file
16 | .fuse_hidden*
17 |
18 | # KDE directory preferences
19 | .directory
20 |
21 | # Linux trash folder which might appear on any partition or disk
22 | .Trash-*
23 |
24 | # .nfs files are created when an open file is removed but is still being accessed
25 | .nfs*
26 |
27 | ### macOS ###
28 | # General
29 | .DS_Store
30 | .AppleDouble
31 | .LSOverride
32 |
33 | # Icon must end with two \r
34 | Icon
35 |
36 |
37 | # Thumbnails
38 | ._*
39 |
40 | # Files that might appear in the root of a volume
41 | .DocumentRevisions-V100
42 | .fseventsd
43 | .Spotlight-V100
44 | .TemporaryItems
45 | .Trashes
46 | .VolumeIcon.icns
47 | .com.apple.timemachine.donotpresent
48 |
49 | # Directories potentially created on remote AFP share
50 | .AppleDB
51 | .AppleDesktop
52 | Network Trash Folder
53 | Temporary Items
54 | .apdisk
55 |
56 | ### macOS Patch ###
57 | # iCloud generated files
58 | *.icloud
59 |
60 | ### Rider ###
61 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
62 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
63 |
64 | # User-specific stuff
65 | .idea/**/workspace.xml
66 | .idea/**/tasks.xml
67 | .idea/**/usage.statistics.xml
68 | .idea/**/dictionaries
69 | .idea/**/shelf
70 |
71 | # AWS User-specific
72 | .idea/**/aws.xml
73 |
74 | # Generated files
75 | .idea/**/contentModel.xml
76 |
77 | # Sensitive or high-churn files
78 | .idea/**/dataSources/
79 | .idea/**/dataSources.ids
80 | .idea/**/dataSources.local.xml
81 | .idea/**/sqlDataSources.xml
82 | .idea/**/dynamic.xml
83 | .idea/**/uiDesigner.xml
84 | .idea/**/dbnavigator.xml
85 |
86 | # Gradle
87 | .idea/**/gradle.xml
88 | .idea/**/libraries
89 |
90 | # Gradle and Maven with auto-import
91 | # When using Gradle or Maven with auto-import, you should exclude module files,
92 | # since they will be recreated, and may cause churn. Uncomment if using
93 | # auto-import.
94 | # .idea/artifacts
95 | # .idea/compiler.xml
96 | # .idea/jarRepositories.xml
97 | # .idea/modules.xml
98 | # .idea/*.iml
99 | # .idea/modules
100 | # *.iml
101 | # *.ipr
102 |
103 | # CMake
104 | cmake-build-*/
105 |
106 | # Mongo Explorer plugin
107 | .idea/**/mongoSettings.xml
108 |
109 | # File-based project format
110 | *.iws
111 |
112 | # IntelliJ
113 | out/
114 |
115 | # mpeltonen/sbt-idea plugin
116 | .idea_modules/
117 |
118 | # JIRA plugin
119 | atlassian-ide-plugin.xml
120 |
121 | # Cursive Clojure plugin
122 | .idea/replstate.xml
123 |
124 | # SonarLint plugin
125 | .idea/sonarlint/
126 |
127 | # Crashlytics plugin (for Android Studio and IntelliJ)
128 | com_crashlytics_export_strings.xml
129 | crashlytics.properties
130 | crashlytics-build.properties
131 | fabric.properties
132 |
133 | # Editor-based Rest Client
134 | .idea/httpRequests
135 |
136 | # Android studio 3.1+ serialized cache file
137 | .idea/caches/build_file_checksums.ser
138 |
139 | ### VisualStudioCode ###
140 | .vscode/*
141 | !.vscode/settings.json
142 | !.vscode/tasks.json
143 | !.vscode/launch.json
144 | !.vscode/extensions.json
145 | !.vscode/*.code-snippets
146 |
147 | # Local History for Visual Studio Code
148 | .history/
149 |
150 | # Built Visual Studio Code Extensions
151 | *.vsix
152 |
153 | ### VisualStudioCode Patch ###
154 | # Ignore all local history of files
155 | .history
156 | .ionide
157 |
158 | ### Windows ###
159 | # Windows thumbnail cache files
160 | Thumbs.db
161 | Thumbs.db:encryptable
162 | ehthumbs.db
163 | ehthumbs_vista.db
164 |
165 | # Dump file
166 | *.stackdump
167 |
168 | # Folder config file
169 | [Dd]esktop.ini
170 |
171 | # Recycle Bin used on file shares
172 | $RECYCLE.BIN/
173 |
174 | # Windows Installer files
175 | *.cab
176 | *.msi
177 | *.msix
178 | *.msm
179 | *.msp
180 |
181 | # Windows shortcuts
182 | *.lnk
183 |
184 | ### VisualStudio ###
185 | ## Ignore Visual Studio temporary files, build results, and
186 | ## files generated by popular Visual Studio add-ons.
187 | ##
188 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
189 |
190 | # User-specific files
191 | *.rsuser
192 | *.suo
193 | *.user
194 | *.userosscache
195 | *.sln.docstates
196 |
197 | # User-specific files (MonoDevelop/Xamarin Studio)
198 | *.userprefs
199 |
200 | # Mono auto generated files
201 | mono_crash.*
202 |
203 | # Build results
204 | [Dd]ebug/
205 | [Dd]ebugPublic/
206 | [Rr]elease/
207 | [Rr]eleases/
208 | x64/
209 | x86/
210 | [Ww][Ii][Nn]32/
211 | [Aa][Rr][Mm]/
212 | [Aa][Rr][Mm]64/
213 | bld/
214 | [Bb]in/
215 | [Oo]bj/
216 | [Ll]og/
217 | [Ll]ogs/
218 |
219 | # Visual Studio 2015/2017 cache/options directory
220 | .vs/
221 | # Uncomment if you have tasks that create the project's static files in wwwroot
222 | #wwwroot/
223 |
224 | # Visual Studio 2017 auto generated files
225 | Generated\ Files/
226 |
227 | # MSTest test Results
228 | [Tt]est[Rr]esult*/
229 | [Bb]uild[Ll]og.*
230 |
231 | # NUnit
232 | *.VisualState.xml
233 | TestResult.xml
234 | nunit-*.xml
235 |
236 | # Build Results of an ATL Project
237 | [Dd]ebugPS/
238 | [Rr]eleasePS/
239 | dlldata.c
240 |
241 | # Benchmark Results
242 | BenchmarkDotNet.Artifacts/
243 |
244 | # .NET Core
245 | project.lock.json
246 | project.fragment.lock.json
247 | artifacts/
248 |
249 | # ASP.NET Scaffolding
250 | ScaffoldingReadMe.txt
251 |
252 | # StyleCop
253 | StyleCopReport.xml
254 |
255 | # Files built by Visual Studio
256 | *_i.c
257 | *_p.c
258 | *_h.h
259 | *.ilk
260 | *.meta
261 | *.obj
262 | *.iobj
263 | *.pch
264 | *.pdb
265 | *.ipdb
266 | *.pgc
267 | *.pgd
268 | *.rsp
269 | *.sbr
270 | *.tlb
271 | *.tli
272 | *.tlh
273 | *.tmp
274 | *.tmp_proj
275 | *_wpftmp.csproj
276 | *.log
277 | *.tlog
278 | *.vspscc
279 | *.vssscc
280 | .builds
281 | *.pidb
282 | *.svclog
283 | *.scc
284 |
285 | # Chutzpah Test files
286 | _Chutzpah*
287 |
288 | # Visual C++ cache files
289 | ipch/
290 | *.aps
291 | *.ncb
292 | *.opendb
293 | *.opensdf
294 | *.sdf
295 | *.cachefile
296 | *.VC.db
297 | *.VC.VC.opendb
298 |
299 | # Visual Studio profiler
300 | *.psess
301 | *.vsp
302 | *.vspx
303 | *.sap
304 |
305 | # Visual Studio Trace Files
306 | *.e2e
307 |
308 | # TFS 2012 Local Workspace
309 | $tf/
310 |
311 | # Guidance Automation Toolkit
312 | *.gpState
313 |
314 | # ReSharper is a .NET coding add-in
315 | _ReSharper*/
316 | *.[Rr]e[Ss]harper
317 | *.DotSettings.user
318 |
319 | # TeamCity is a build add-in
320 | _TeamCity*
321 |
322 | # DotCover is a Code Coverage Tool
323 | *.dotCover
324 |
325 | # AxoCover is a Code Coverage Tool
326 | .axoCover/*
327 | !.axoCover/settings.json
328 |
329 | # Coverlet is a free, cross platform Code Coverage Tool
330 | coverage*.json
331 | coverage*.xml
332 | coverage*.info
333 |
334 | # Visual Studio code coverage results
335 | *.coverage
336 | *.coveragexml
337 |
338 | # NCrunch
339 | _NCrunch_*
340 | .*crunch*.local.xml
341 | nCrunchTemp_*
342 |
343 | # MightyMoose
344 | *.mm.*
345 | AutoTest.Net/
346 |
347 | # Web workbench (sass)
348 | .sass-cache/
349 |
350 | # Installshield output folder
351 | [Ee]xpress/
352 |
353 | # DocProject is a documentation generator add-in
354 | DocProject/buildhelp/
355 | DocProject/Help/*.HxT
356 | DocProject/Help/*.HxC
357 | DocProject/Help/*.hhc
358 | DocProject/Help/*.hhk
359 | DocProject/Help/*.hhp
360 | DocProject/Help/Html2
361 | DocProject/Help/html
362 |
363 | # Click-Once directory
364 | publish/
365 |
366 | # Publish Web Output
367 | *.[Pp]ublish.xml
368 | *.azurePubxml
369 | # Note: Comment the next line if you want to checkin your web deploy settings,
370 | # but database connection strings (with potential passwords) will be unencrypted
371 | *.pubxml
372 | *.publishproj
373 |
374 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
375 | # checkin your Azure Web App publish settings, but sensitive information contained
376 | # in these scripts will be unencrypted
377 | PublishScripts/
378 |
379 | # NuGet Packages
380 | *.nupkg
381 | # NuGet Symbol Packages
382 | *.snupkg
383 | # The packages folder can be ignored because of Package Restore
384 | **/[Pp]ackages/*
385 | # except build/, which is used as an MSBuild target.
386 | !**/[Pp]ackages/build/
387 | # Uncomment if necessary however generally it will be regenerated when needed
388 | #!**/[Pp]ackages/repositories.config
389 | # NuGet v3's project.json files produces more ignorable files
390 | *.nuget.props
391 | *.nuget.targets
392 |
393 | # Microsoft Azure Build Output
394 | csx/
395 | *.build.csdef
396 |
397 | # Microsoft Azure Emulator
398 | ecf/
399 | rcf/
400 |
401 | # Windows Store app package directories and files
402 | AppPackages/
403 | BundleArtifacts/
404 | Package.StoreAssociation.xml
405 | _pkginfo.txt
406 | *.appx
407 | *.appxbundle
408 | *.appxupload
409 |
410 | # Visual Studio cache files
411 | # files ending in .cache can be ignored
412 | *.[Cc]ache
413 | # but keep track of directories ending in .cache
414 | !?*.[Cc]ache/
415 |
416 | # Others
417 | ClientBin/
418 | ~$*
419 | *.dbmdl
420 | *.dbproj.schemaview
421 | *.jfm
422 | *.pfx
423 | *.publishsettings
424 | orleans.codegen.cs
425 |
426 | # Including strong name files can present a security risk
427 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
428 | #*.snk
429 |
430 | # Since there are multiple workflows, uncomment next line to ignore bower_components
431 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
432 | #bower_components/
433 |
434 | # RIA/Silverlight projects
435 | Generated_Code/
436 |
437 | # Backup & report files from converting an old project file
438 | # to a newer Visual Studio version. Backup files are not needed,
439 | # because we have git ;-)
440 | _UpgradeReport_Files/
441 | Backup*/
442 | UpgradeLog*.XML
443 | UpgradeLog*.htm
444 | ServiceFabricBackup/
445 | *.rptproj.bak
446 |
447 | # SQL Server files
448 | *.mdf
449 | *.ldf
450 | *.ndf
451 |
452 | # Business Intelligence projects
453 | *.rdl.data
454 | *.bim.layout
455 | *.bim_*.settings
456 | *.rptproj.rsuser
457 | *- [Bb]ackup.rdl
458 | *- [Bb]ackup ([0-9]).rdl
459 | *- [Bb]ackup ([0-9][0-9]).rdl
460 |
461 | # Microsoft Fakes
462 | FakesAssemblies/
463 |
464 | # GhostDoc plugin setting file
465 | *.GhostDoc.xml
466 |
467 | # Node.js Tools for Visual Studio
468 | .ntvs_analysis.dat
469 | node_modules/
470 |
471 | # Visual Studio 6 build log
472 | *.plg
473 |
474 | # Visual Studio 6 workspace options file
475 | *.opt
476 |
477 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
478 | *.vbw
479 |
480 | # Visual Studio 6 auto-generated project file (contains which files were open etc.)
481 | *.vbp
482 |
483 | # Visual Studio 6 workspace and project file (working project files containing files to include in project)
484 | *.dsw
485 | *.dsp
486 |
487 | # Visual Studio 6 technical files
488 |
489 | # Visual Studio LightSwitch build output
490 | **/*.HTMLClient/GeneratedArtifacts
491 | **/*.DesktopClient/GeneratedArtifacts
492 | **/*.DesktopClient/ModelManifest.xml
493 | **/*.Server/GeneratedArtifacts
494 | **/*.Server/ModelManifest.xml
495 | _Pvt_Extensions
496 |
497 | # Paket dependency manager
498 | .paket/paket.exe
499 | paket-files/
500 |
501 | # FAKE - F# Make
502 | .fake/
503 |
504 | # CodeRush personal settings
505 | .cr/personal
506 |
507 | # Python Tools for Visual Studio (PTVS)
508 | __pycache__/
509 | *.pyc
510 |
511 | # Cake - Uncomment if you are using it
512 | # tools/**
513 | # !tools/packages.config
514 |
515 | # Tabs Studio
516 | *.tss
517 |
518 | # Telerik's JustMock configuration file
519 | *.jmconfig
520 |
521 | # BizTalk build output
522 | *.btp.cs
523 | *.btm.cs
524 | *.odx.cs
525 | *.xsd.cs
526 |
527 | # OpenCover UI analysis results
528 | OpenCover/
529 |
530 | # Azure Stream Analytics local run output
531 | ASALocalRun/
532 |
533 | # MSBuild Binary and Structured Log
534 | *.binlog
535 |
536 | # NVidia Nsight GPU debugger configuration file
537 | *.nvuser
538 |
539 | # MFractors (Xamarin productivity tool) working folder
540 | .mfractor/
541 |
542 | # Local History for Visual Studio
543 | .localhistory/
544 |
545 | # Visual Studio History (VSHistory) files
546 | .vshistory/
547 |
548 | # BeatPulse healthcheck temp database
549 | healthchecksdb
550 |
551 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
552 | MigrationBackup/
553 |
554 | # Ionide (cross platform F# VS Code tools) working folder
555 | .ionide/
556 |
557 | # Fody - auto-generated XML schema
558 | FodyWeavers.xsd
559 |
560 | # VS Code files for those working on multiple tools
561 | *.code-workspace
562 |
563 | # Local History for Visual Studio Code
564 |
565 | # Windows Installer files from build outputs
566 |
567 | # JetBrains Rider
568 | *.sln.iml
569 |
570 | ### VisualStudio Patch ###
571 | # Additional files built by Visual Studio
572 |
573 | .pre-commit-trivy-cache
574 |
--------------------------------------------------------------------------------
/.idea/.idea.WebApiTemplate/.idea/.name:
--------------------------------------------------------------------------------
1 | WebApiTemplate
--------------------------------------------------------------------------------
/.idea/.idea.WebApiTemplate/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/.idea.WebApiTemplate/.idea/indexLayout.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/.idea.WebApiTemplate/.idea/projectSettingsUpdater.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.idea.WebApiTemplate/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | repos:
3 | - repo: https://github.com/thoughtworks/talisman
4 | rev: "v1.32.0"
5 | hooks:
6 | - id: talisman-commit
7 | entry: cmd --githook pre-commit
8 | - repo: local
9 | hooks:
10 | - id: trivy-fs
11 | name: trivyfs
12 | entry: aquasec/trivy fs --scanners vuln,misconfig --cache-dir /src/.pre-commit-trivy-cache --exit-code 0 ./
13 | language: docker_image
14 | pass_filenames: false
15 | always_run: true
16 | verbose: true
17 | # dotnet tools
18 | - id: dotnet-tool-restore
19 | name: Install .NET tools
20 | entry: dotnet tool restore
21 | language: system
22 | always_run: true
23 | pass_filenames: false
24 | stages:
25 | - commit
26 | - push
27 | - post-checkout
28 | - post-rewrite
29 | description: Install the .NET tools listed at .config/dotnet-tools.json.
30 | - id: csharpier
31 | name: Run CSharpier on C# files
32 | entry: dotnet tool run dotnet-csharpier
33 | language: system
34 | types:
35 | - c#
36 | description: CSharpier is an opinionated C# formatter inspired by Prettier.
37 |
--------------------------------------------------------------------------------
/.talismanrc:
--------------------------------------------------------------------------------
1 | fileignoreconfig:
2 | - filename: src/WebApiTemplate.Application/QueryHandlerCachingDecorator.cs
3 | checksum: ec7a355dd536358b6b67ff15df3af6918344e5fc404d3ad07d7dd096ea841af7
4 | - filename: test/WebApiTemplate.IntegrationTests/AppWebApplicationFactory.cs
5 | checksum: 3e27ba162115530609565834ee7eb4d091b3fda0b94813571410730f334a997d
6 | - filename: test/WebApiTemplate.IntegrationTests/Constants.cs
7 | checksum: bc1f6aa439357b6713f1ec0de88155ea744cc2aa27ac9e7bf71e5a8d8e2a747b
8 | version: ""
9 |
--------------------------------------------------------------------------------
/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | net8.0
4 | latest
5 | enable
6 | enable
7 | true
8 | true
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 | true
4 | true
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Luca Dalla Valle
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ASP.NET Core WebApi template
2 |
3 | This template can be used to bootstrap a working full-fledged ASP.NET Web Api project with a single CLI command (see below).
4 |
5 | It contains what I consider to be best practices/patterns, such as CQRS, Mediator, Clean Architecture.
6 |
7 | ## :star: Like it? Give a star
8 | If you like this project, you learned something from it or you are using it in your applications, please press the star button. Thanks!
9 |
10 | ## Motivation
11 | I found implementations of similar samples/templates to often be overly complicated and over-engineered (IMO). This is an effort to create a more approachable, more maintainable solution that can be used as a starting point for the majority of real-world projects while, at the same time, striving to reach a sensible balance between flexibility and complexity.
12 |
13 | ## Features
14 | - Based on .NET 8 to have access to the latest features
15 | - Simplified Startup.cs hosting model
16 | - [CQRS](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs) with full separation between Read and Write repositories
17 | - Simple [Mediator](https://en.wikipedia.org/wiki/Mediator_pattern) abstraction for CQRS and implementation relying on the chosen Dependency Injection container (see [HumbleMediator](https://github.com/undrivendev/HumbleMediator))
18 | - Project structure following [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) principles
19 | - Read repositories based on [Dapper](https://dapperlib.github.io/Dapper/) (Raw SQL) for fastest query execution times
20 | - Write repositories based on [Entity Framework Core](https://github.com/dotnet/efcore) to take advantage on the built-in change tracking mechanism
21 | - [PostgreSQL](https://www.postgresql.org/) open source database as data store (easily replaceable with any Entity Framework-supported data stores)
22 | - Database configured to use snake_case naming convention via [EFCore.NamingConventions](https://github.com/efcore/EFCore.NamingConventions)
23 | - Migrations handled by Entity Framework and automatically applied during startup (in dev environment)
24 | - [SimpleInjector](https://simpleinjector.org/) open-source DI container integration for advanced service registration scenarios
25 | - [Aspect-oriented programming](https://en.wikipedia.org/wiki/Aspect-oriented_programming) using [Decorators](https://en.wikipedia.org/wiki/Decorator_pattern) on the above-mentioned mediator
26 | - Logging: [QueryHandlerLoggingDecorator](src/Application/Logging/QueryHandlerLoggingDecorator.cs) and [CommandHandlerLoggingDecorator](src/Application/Logging/CommandHandlerLoggingDecorator.cs)
27 | - Caching: [QueryHandlerCachingDecorator](src/Application/QueryHandlerCachingDecorator.cs)
28 | - Validation: [CommandHandlerValidationDecorator](src/Application/Validation/CommandHandlerValidationDecorator.cs) and [QueryHandlerValidationDecorator](src/Application/Validation/QueryHandlerValidationDecorator.cs)
29 | - Structured logging using the standard [MEL](https://github.com/dotnet/runtime/tree/main/src/libraries/Microsoft.Extensions.Logging.Abstractions) interface with the open-source [Serilog](https://serilog.net/) logging library implementation
30 | - Cache-friendly [Dockerfile](src/Api/Dockerfile)
31 | - Expressive testing using [xUnit](https://xunit.net/) and [FluentAssertions](https://fluentassertions.com/)
32 | - Integration testing using real database implementation with [Testcontainers](https://dotnet.testcontainers.org/)
33 | - [Central Package Management](https://learn.microsoft.com/en-us/nuget/consume-packages/Central-Package-Management)
34 |
35 | ## Usage
36 | ### 1. Bootstrap your project
37 | Here are a couple of ways to bootstrap a new project starting from this template.
38 | #### Cookiecutter template
39 | Probably the best way to bootstrap this project, with just one command, but some dependencies are needed.
40 | 1. Make sure Python is installed
41 | 2. Install [cookiecutter](https://www.cookiecutter.io/).
42 | 3. Bootstrap initial project with the following command: `cookiecutter gh:undrivendev/template-webapi-aspnet --checkout cookiecutter`
43 | #### GitHub template
44 | You could use this project as a GitHub template and clone it in your personal account by using the `Use this template` green button on the top of the page.
45 |
46 | Then you'd have to rename classes and namespaces.
47 |
48 |
49 | ### 2. Apply initial migration
50 | When you have the project ready, it's time to create the initial migration using [dotnet-ef](https://docs.microsoft.com/en-us/ef/core/cli/dotnet) (or if you use Rider, like me, you can try [this plugin](https://plugins.jetbrains.com/plugin/18147-entity-framework-core-ui)).
51 |
52 | Here's an example command using the default solution name, if you changed it you would have to adapt it accordingly:
53 |
54 | ```sh
55 | dotnet ef migrations add --project ./src/Infrastructure/Infrastructure.csproj --context AppDbContext --startup-project ./src/Api/Api.csproj InitialMigration
56 | ```
57 |
58 | The above migration is applied automatically during startup in the dev environment.
59 |
60 | ### 3. Start the application
61 | The default API endpoints should be testable from the [Swagger UI](http://localhost:5000/swagger/index.html).
62 |
63 | Enjoy!
64 |
--------------------------------------------------------------------------------
/WebApiTemplate.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Core\Core.csproj", "{D27E1BC8-0554-43AE-B98B-057641726FC4}"
4 | EndProject
5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{D7B044A7-060A-4E6B-A457-AF93AB03C095}"
6 | EndProject
7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApi", "src\WebApi\WebApi.csproj", "{53C0477D-D1EE-4D32-9F13-84CB55133F70}"
8 | EndProject
9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{9D241899-3FCE-4DDA-8349-A8B3FCA48F03}"
10 | EndProject
11 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{61CD5F39-6A48-4620-B894-C30B203CC11C}"
12 | ProjectSection(SolutionItems) = preProject
13 | tests\Directory.Build.props = tests\Directory.Build.props
14 | tests\Directory.Packages.props = tests\Directory.Packages.props
15 | EndProjectSection
16 | EndProject
17 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{F906C471-E4EB-4B4C-B8DE-F7B8BAF37A6D}"
18 | EndProject
19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTests", "tests\IntegrationTests\IntegrationTests.csproj", "{A53F16CF-3876-46DC-AFD3-2F27215F893C}"
20 | EndProject
21 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2CDC2B0B-891C-442D-B9E3-0DF5946DDED2}"
22 | ProjectSection(SolutionItems) = preProject
23 | LICENSE = LICENSE
24 | README.md = README.md
25 | Directory.Build.props = Directory.Build.props
26 | Directory.Packages.props = Directory.Packages.props
27 | .editorconfig = .editorconfig
28 | .gitattributes = .gitattributes
29 | .gitignore = .gitignore
30 | .pre-commit-config.yaml = .pre-commit-config.yaml
31 | .talismanrc = .talismanrc
32 | EndProjectSection
33 | EndProject
34 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9BBF62A-E6FE-4F01-BDBB-B80CE35B24A5}"
35 | ProjectSection(SolutionItems) = preProject
36 | src\Directory.Build.props = src\Directory.Build.props
37 | src\Directory.Packages.props = src\Directory.Packages.props
38 | EndProjectSection
39 | EndProject
40 | Global
41 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
42 | Debug|Any CPU = Debug|Any CPU
43 | Release|Any CPU = Release|Any CPU
44 | EndGlobalSection
45 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
46 | {D27E1BC8-0554-43AE-B98B-057641726FC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47 | {D27E1BC8-0554-43AE-B98B-057641726FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
48 | {D27E1BC8-0554-43AE-B98B-057641726FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
49 | {D27E1BC8-0554-43AE-B98B-057641726FC4}.Release|Any CPU.Build.0 = Release|Any CPU
50 | {D7B044A7-060A-4E6B-A457-AF93AB03C095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
51 | {D7B044A7-060A-4E6B-A457-AF93AB03C095}.Debug|Any CPU.Build.0 = Debug|Any CPU
52 | {D7B044A7-060A-4E6B-A457-AF93AB03C095}.Release|Any CPU.ActiveCfg = Release|Any CPU
53 | {D7B044A7-060A-4E6B-A457-AF93AB03C095}.Release|Any CPU.Build.0 = Release|Any CPU
54 | {53C0477D-D1EE-4D32-9F13-84CB55133F70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
55 | {53C0477D-D1EE-4D32-9F13-84CB55133F70}.Debug|Any CPU.Build.0 = Debug|Any CPU
56 | {53C0477D-D1EE-4D32-9F13-84CB55133F70}.Release|Any CPU.ActiveCfg = Release|Any CPU
57 | {53C0477D-D1EE-4D32-9F13-84CB55133F70}.Release|Any CPU.Build.0 = Release|Any CPU
58 | {9D241899-3FCE-4DDA-8349-A8B3FCA48F03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
59 | {9D241899-3FCE-4DDA-8349-A8B3FCA48F03}.Debug|Any CPU.Build.0 = Debug|Any CPU
60 | {9D241899-3FCE-4DDA-8349-A8B3FCA48F03}.Release|Any CPU.ActiveCfg = Release|Any CPU
61 | {9D241899-3FCE-4DDA-8349-A8B3FCA48F03}.Release|Any CPU.Build.0 = Release|Any CPU
62 | {F906C471-E4EB-4B4C-B8DE-F7B8BAF37A6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
63 | {F906C471-E4EB-4B4C-B8DE-F7B8BAF37A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
64 | {F906C471-E4EB-4B4C-B8DE-F7B8BAF37A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
65 | {F906C471-E4EB-4B4C-B8DE-F7B8BAF37A6D}.Release|Any CPU.Build.0 = Release|Any CPU
66 | {A53F16CF-3876-46DC-AFD3-2F27215F893C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
67 | {A53F16CF-3876-46DC-AFD3-2F27215F893C}.Debug|Any CPU.Build.0 = Debug|Any CPU
68 | {A53F16CF-3876-46DC-AFD3-2F27215F893C}.Release|Any CPU.ActiveCfg = Release|Any CPU
69 | {A53F16CF-3876-46DC-AFD3-2F27215F893C}.Release|Any CPU.Build.0 = Release|Any CPU
70 | EndGlobalSection
71 | GlobalSection(NestedProjects) = preSolution
72 | {F906C471-E4EB-4B4C-B8DE-F7B8BAF37A6D} = {61CD5F39-6A48-4620-B894-C30B203CC11C}
73 | {A53F16CF-3876-46DC-AFD3-2F27215F893C} = {61CD5F39-6A48-4620-B894-C30B203CC11C}
74 | {C9BBF62A-E6FE-4F01-BDBB-B80CE35B24A5} = {2CDC2B0B-891C-442D-B9E3-0DF5946DDED2}
75 | EndGlobalSection
76 | EndGlobal
77 |
--------------------------------------------------------------------------------
/src/Application/Application.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WebApiTemplate.Application
5 | WebApiTemplate.Application
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/CreateCustomerCommand.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core.Customers;
3 |
4 | namespace WebApiTemplate.Application.Customers.Commands;
5 |
6 | ///
7 | /// implementation for creating a new customer entity.
8 | ///
9 | /// The instance to create.
10 | public record CreateCustomerCommand(Customer Customer) : ICommand;
11 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/CreateCustomerCommandHandler.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core;
3 | using WebApiTemplate.Core.Customers;
4 |
5 | namespace WebApiTemplate.Application.Customers.Commands;
6 |
7 | ///
8 | /// implementation for creating a new customer entity.
9 | ///
10 | public class CreateCustomerCommandHandler(
11 | IUnitOfWorkFactory uowFactory,
12 | ICustomerWriteRepository repository
13 | ) : CustomerCommandHandlerBase(uowFactory, repository), ICommandHandler
14 | {
15 | ///
16 | /// Handle the command to create a new customer entity.
17 | ///
18 | /// The to handle.
19 | /// An optional .
20 | /// The id of the newly-created entity.
21 | /// Thrown if something unexpected happens.
22 | public async Task Handle(
23 | CreateCustomerCommand command,
24 | CancellationToken cancellationToken = default
25 | )
26 | {
27 | await using var uow = await _uowFactory.Create(cancellationToken);
28 | await _repository.Create(command.Customer, uow);
29 | await uow.Commit(cancellationToken);
30 | return command.Customer.Id ?? throw new InvalidOperationException("New customer has no Id");
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/CreateCustomerCommandValidator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 |
3 | namespace WebApiTemplate.Application.Customers.Commands;
4 |
5 | ///
6 | /// Validator for .
7 | ///
8 | public sealed class CreateCustomerCommandValidator : AbstractValidator
9 | {
10 | ///
11 | /// Defines the validation rules for the .
12 | ///
13 | public CreateCustomerCommandValidator()
14 | {
15 | RuleFor(e => e.Customer).NotEmpty();
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/CustomerCommandHandlerBase.cs:
--------------------------------------------------------------------------------
1 | using WebApiTemplate.Core;
2 | using WebApiTemplate.Core.Customers;
3 |
4 | namespace WebApiTemplate.Application.Customers.Commands;
5 |
6 | ///
7 | /// Base class for customer command handlers.
8 | ///
9 | public abstract class CustomerCommandHandlerBase
10 | {
11 | ///
12 | /// The unit of work factory for creating new unit of work instances.
13 | ///
14 | protected readonly IUnitOfWorkFactory _uowFactory;
15 |
16 | ///
17 | /// The customer write repository for manipulating customer entities.
18 | ///
19 | protected readonly ICustomerWriteRepository _repository;
20 |
21 | ///
22 | /// Default constructor for the customer command handler base class.
23 | ///
24 | ///
25 | ///
26 | protected CustomerCommandHandlerBase(
27 | IUnitOfWorkFactory uowFactory,
28 | ICustomerWriteRepository repository
29 | )
30 | {
31 | _uowFactory = uowFactory;
32 | _repository = repository;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/DeleteCustomerCommand.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core;
3 |
4 | namespace WebApiTemplate.Application.Customers.Commands;
5 |
6 | ///
7 | /// implementation for deleting a customer entity.
8 | ///
9 | /// The id of the entity to delete.
10 | public record DeleteCustomerCommand(int Id) : ICommand;
11 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/DeleteCustomerCommandHandler.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core;
3 | using WebApiTemplate.Core.Customers;
4 |
5 | namespace WebApiTemplate.Application.Customers.Commands;
6 |
7 | ///
8 | /// implementation for deleting a customer entity.
9 | ///
10 | public class DeleteCustomerCommandHandler(
11 | IUnitOfWorkFactory uowFactory,
12 | ICustomerWriteRepository repository
13 | )
14 | : CustomerCommandHandlerBase(uowFactory, repository),
15 | ICommandHandler
16 | {
17 | ///
18 | /// Handle the command to delete a customer entity.
19 | ///
20 | /// The to handle.
21 | /// An optional .
22 | /// An instance of the class.
23 | public async Task Handle(
24 | DeleteCustomerCommand command,
25 | CancellationToken cancellationToken = default
26 | )
27 | {
28 | await using var uow = await _uowFactory.Create(cancellationToken);
29 | await _repository.Delete(command.Id, uow);
30 | await uow.Commit(cancellationToken);
31 | return Nothing.Instance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/DeleteCustomerCommandValidator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 |
3 | namespace WebApiTemplate.Application.Customers.Commands;
4 |
5 | ///
6 | /// Validator for .
7 | ///
8 | public sealed class DeleteCustomerCommandValidator : AbstractValidator
9 | {
10 | ///
11 | /// Defines the validation rules for the .
12 | ///
13 | public DeleteCustomerCommandValidator()
14 | {
15 | RuleFor(e => e.Id).Must(e => e > 0);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/UpdateCustomerCommand.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core;
3 | using WebApiTemplate.Core.Customers;
4 |
5 | namespace WebApiTemplate.Application.Customers.Commands;
6 |
7 | ///
8 | /// implementation for updating a customer entity.
9 | ///
10 | /// The id of the entity to update.
11 | /// The updated entity.
12 | public record UpdateCustomerCommand(int Id, Customer Customer) : ICommand;
13 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/UpdateCustomerCommandHandler.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core;
3 | using WebApiTemplate.Core.Customers;
4 |
5 | namespace WebApiTemplate.Application.Customers.Commands;
6 |
7 | ///
8 | /// Command handler for updating a .
9 | ///
10 | /// The factory to create new instances of .
11 | /// The repository for writing entities.
12 | public class UpdateCustomerCommandHandler(
13 | IUnitOfWorkFactory uowFactory,
14 | ICustomerWriteRepository repository
15 | )
16 | : CustomerCommandHandlerBase(uowFactory, repository),
17 | ICommandHandler
18 | {
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | /// The command to handle.
23 | /// The token to monitor for cancellation requests.
24 | ///
25 | public async Task Handle(
26 | UpdateCustomerCommand command,
27 | CancellationToken cancellationToken = default
28 | )
29 | {
30 | await using var uow = await _uowFactory.Create(cancellationToken);
31 | await _repository.Update(command.Customer, uow);
32 | await uow.Commit(cancellationToken);
33 | return Nothing.Instance;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/Application/Customers/Commands/UpdateCustomerCommandValidator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 |
3 | namespace WebApiTemplate.Application.Customers.Commands;
4 |
5 | ///
6 | /// Validator for the command.
7 | ///
8 | public sealed class UpdateCustomerCommandValidator : AbstractValidator
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public UpdateCustomerCommandValidator()
14 | {
15 | RuleFor(e => e.Id).Must(e => e > 0);
16 | RuleFor(e => e.Customer).NotEmpty();
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Application/Customers/Queries/GetCustomerByIdQuery.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core.Customers;
3 |
4 | namespace WebApiTemplate.Application.Customers.Queries;
5 |
6 | ///
7 | /// Query to get a by its id.
8 | ///
9 | ///
10 | public sealed record GetCustomerByIdQuery(int Id) : IQuery;
11 |
--------------------------------------------------------------------------------
/src/Application/Customers/Queries/GetCustomerByIdQueryHandler.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using WebApiTemplate.Core.Customers;
3 |
4 | namespace WebApiTemplate.Application.Customers.Queries;
5 |
6 | ///
7 | /// Query to get a by its id.
8 | ///
9 | public class GetCustomerByIdQueryHandler : IQueryHandler
10 | {
11 | private readonly ICustomerReadRepository _customerReadRepository;
12 |
13 | ///
14 | /// Initializes a new instance of the class.
15 | ///
16 | ///
17 | public GetCustomerByIdQueryHandler(ICustomerReadRepository customerReadRepository)
18 | {
19 | _customerReadRepository = customerReadRepository;
20 | }
21 |
22 | ///
23 | /// Handles the .
24 | ///
25 | /// The query to handle.
26 | /// The token to monitor for cancellation requests.
27 | ///
28 | public Task Handle(
29 | GetCustomerByIdQuery query,
30 | CancellationToken cancellationToken = default
31 | ) => _customerReadRepository.GetById(query.Id);
32 | }
33 |
--------------------------------------------------------------------------------
/src/Application/Customers/Queries/GetCustomerByIdQueryValidator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 |
3 | namespace WebApiTemplate.Application.Customers.Queries;
4 |
5 | ///
6 | /// Validator for the query.
7 | ///
8 | public sealed class GetCustomerByIdQueryValidator : AbstractValidator
9 | {
10 | ///
11 | /// Initializes a new instance of the class.
12 | ///
13 | public GetCustomerByIdQueryValidator()
14 | {
15 | RuleFor(e => e.Id).Must(e => e > 0);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Application/Logging/BaseLoggingDecorator.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Logging;
2 |
3 | namespace WebApiTemplate.Application.Logging;
4 |
5 | ///
6 | /// Base class for logging decorators.
7 | ///
8 | ///
9 | public abstract class BaseLoggingDecorator
10 | {
11 | private readonly ILogger> _logger;
12 |
13 | ///
14 | /// Initializes a new instance of the class.
15 | ///
16 | ///
17 | protected BaseLoggingDecorator(ILogger> logger)
18 | {
19 | _logger = logger;
20 | }
21 |
22 | ///
23 | /// Handles and logs the message.
24 | ///
25 | /// The request to handle.
26 | /// The token to monitor for cancellation requests.
27 | /// The next delegate to call.
28 | /// The result type of the request.
29 | /// The result of the decorated handler.
30 | protected async Task HandleAndLogMessage(
31 | TRequest request,
32 | CancellationToken cancellationToken,
33 | Func> next
34 | )
35 | {
36 | _logger.LogInformation("START handling request {@request}", request);
37 | var result = await next(request, cancellationToken);
38 | _logger.LogInformation("FINISH handling request {@request}", request);
39 | return result;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Application/Logging/CommandHandlerLoggingDecorator.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using Microsoft.Extensions.Logging;
3 |
4 | namespace WebApiTemplate.Application.Logging;
5 |
6 | ///
7 | /// Decorator for logging command handlers.
8 | ///
9 | /// The type of the command to handle.
10 | /// The type of the command result.
11 | public sealed class CommandHandlerLoggingDecorator
12 | : BaseLoggingDecorator,
13 | ICommandHandler
14 | where TCommand : ICommand
15 | {
16 | private readonly ICommandHandler _decorated;
17 |
18 | ///
19 | /// Initializes a new instance of the class.
20 | ///
21 | /// The command handler to decorate.
22 | /// The logger to use.
23 | public CommandHandlerLoggingDecorator(
24 | ICommandHandler decorated,
25 | ILogger> logger
26 | )
27 | : base(logger)
28 | {
29 | _decorated = decorated;
30 | }
31 |
32 | ///
33 | /// Handles the command and logs the message.
34 | ///
35 | /// The command to handle.
36 | /// The token to monitor for cancellation requests.
37 | ///
38 | public async Task Handle(
39 | TCommand request,
40 | CancellationToken cancellationToken
41 | ) => await HandleAndLogMessage(request, cancellationToken, _decorated.Handle);
42 | }
43 |
--------------------------------------------------------------------------------
/src/Application/Logging/QueryHandlerLoggingDecorator.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using Microsoft.Extensions.Logging;
3 |
4 | namespace WebApiTemplate.Application.Logging;
5 |
6 | ///
7 | /// Decorator for logging command handlers.
8 | ///
9 | ///
10 | ///
11 | public sealed class QueryHandlerLoggingDecorator
12 | : BaseLoggingDecorator,
13 | IQueryHandler
14 | where TQuery : IQuery
15 | {
16 | private readonly IQueryHandler _decorated;
17 |
18 | ///
19 | /// Initializes a new instance of the class.
20 | ///
21 | /// The query handler to decorate.
22 | /// The logger to use.
23 | public QueryHandlerLoggingDecorator(
24 | IQueryHandler decorated,
25 | ILogger> logger
26 | )
27 | : base(logger)
28 | {
29 | _decorated = decorated;
30 | }
31 |
32 | ///
33 | /// Handles the query and logs the message.
34 | ///
35 | /// The query to handle.
36 | /// The token to monitor for cancellation requests.
37 | /// The result of the decorated handler.
38 | public async Task Handle(TQuery request, CancellationToken cancellationToken) =>
39 | await HandleAndLogMessage(request, cancellationToken, _decorated.Handle);
40 | }
41 |
--------------------------------------------------------------------------------
/src/Application/QueryHandlerCachingDecorator.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json;
2 | using HumbleMediator;
3 | using Microsoft.Extensions.Caching.Distributed;
4 |
5 | namespace WebApiTemplate.Application;
6 |
7 | ///
8 | /// Decorator for caching query results.
9 | ///
10 | /// The type of the query to handle.
11 | /// The type of the query result.
12 | public sealed class QueryHandlerCachingDecorator
13 | : IQueryHandler
14 | where TQuery : IQuery
15 | {
16 | private readonly IQueryHandler _decorated;
17 | private readonly IDistributedCache _cache;
18 |
19 | ///
20 | /// Initializes a new instance of the class.
21 | ///
22 | /// The query handler to decorate.
23 | /// The cache to use.
24 | public QueryHandlerCachingDecorator(
25 | IQueryHandler decorated,
26 | IDistributedCache cache
27 | )
28 | {
29 | _decorated = decorated;
30 | _cache = cache;
31 | }
32 |
33 | ///
34 | /// Handles the query and caches the result.
35 | ///
36 | /// The query to handle.
37 | /// The token to monitor for cancellation requests.
38 | ///
39 | public async Task Handle(TQuery request, CancellationToken cancellationToken)
40 | {
41 | var cacheKey = $"{typeof(TQuery).Name}-{JsonSerializer.Serialize(request)}";
42 | var cachedResult = await _cache.GetStringAsync(cacheKey, cancellationToken);
43 | if (!string.IsNullOrWhiteSpace(cachedResult))
44 | {
45 | return JsonSerializer.Deserialize(cachedResult)!;
46 | }
47 |
48 | var result = await _decorated.Handle(request, cancellationToken);
49 | await _cache.SetStringAsync(
50 | cacheKey,
51 | JsonSerializer.Serialize(result),
52 | new DistributedCacheEntryOptions
53 | {
54 | AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), // TODO: parametrize
55 | },
56 | cancellationToken
57 | );
58 |
59 | return result;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Application/Validation/BaseValidationDecorator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 |
3 | namespace WebApiTemplate.Application.Validation;
4 |
5 | ///
6 | /// Base class for validation decorators.
7 | ///
8 | ///
9 | public abstract class BaseValidationDecorator
10 | {
11 | private readonly IEnumerable> _validators;
12 |
13 | ///
14 | /// Initializes a new instance of the class.
15 | ///
16 | ///
17 | protected BaseValidationDecorator(IEnumerable> validators)
18 | {
19 | _validators = validators;
20 | }
21 |
22 | ///
23 | /// Validates the request.
24 | ///
25 | /// The request to validate.
26 | /// The token to monitor for cancellation requests.
27 | protected async Task Validate(TRequest request, CancellationToken cancellationToken)
28 | {
29 | if (!_validators.Any())
30 | {
31 | return;
32 | }
33 |
34 | foreach (var validator in _validators)
35 | {
36 | await validator.ValidateAndThrowAsync(request, cancellationToken);
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Application/Validation/CommandHandlerValidationDecorator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 | using HumbleMediator;
3 |
4 | namespace WebApiTemplate.Application.Validation;
5 |
6 | ///
7 | /// Decorator for validating command handlers.
8 | ///
9 | /// The type of the command to handle.
10 | /// The type of the command result.
11 | public sealed class CommandHandlerValidationDecorator
12 | : BaseValidationDecorator,
13 | ICommandHandler
14 | where TCommand : ICommand
15 | {
16 | private readonly ICommandHandler _decorated;
17 |
18 | ///
19 | /// Initializes a new instance of the class.
20 | ///
21 | /// The command handler to decorate.
22 | /// The validators to use.
23 | public CommandHandlerValidationDecorator(
24 | ICommandHandler decorated,
25 | IEnumerable> validators
26 | )
27 | : base(validators)
28 | {
29 | _decorated = decorated;
30 | }
31 |
32 | ///
33 | /// Validates the command and handles it.
34 | ///
35 | /// The command to handle.
36 | /// The token to monitor for cancellation requests.
37 | ///
38 | public async Task Handle(TCommand request, CancellationToken cancellationToken)
39 | {
40 | await Validate(request, cancellationToken);
41 | return await _decorated.Handle(request, cancellationToken);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Application/Validation/QueryHandlerValidationDecorator.cs:
--------------------------------------------------------------------------------
1 | using FluentValidation;
2 | using HumbleMediator;
3 |
4 | namespace WebApiTemplate.Application.Validation;
5 |
6 | ///
7 | /// Decorator for validating query handlers.
8 | ///
9 | ///
10 | ///
11 | public sealed class QueryHandlerValidationDecorator
12 | : BaseValidationDecorator,
13 | IQueryHandler
14 | where TQuery : IQuery
15 | {
16 | private readonly IQueryHandler _decorated;
17 |
18 | ///
19 | /// Initializes a new instance of the class.
20 | ///
21 | /// The query handler to decorate.
22 | /// The validators to use.
23 | public QueryHandlerValidationDecorator(
24 | IQueryHandler decorated,
25 | IEnumerable> validators
26 | )
27 | : base(validators)
28 | {
29 | _decorated = decorated;
30 | }
31 |
32 | ///
33 | /// Validates the query and handles it.
34 | ///
35 | ///
36 | ///
37 | ///
38 | public async Task Handle(TQuery request, CancellationToken cancellationToken)
39 | {
40 | await Validate(request, cancellationToken);
41 | return await _decorated.Handle(request, cancellationToken);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Core/BaseEntity.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core;
2 |
3 | ///
4 | /// Base class for all domain entities.
5 | ///
6 | /// The id of the entity.
7 | public abstract record BaseEntity(int? Id);
8 |
--------------------------------------------------------------------------------
/src/Core/Core.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WebApiTemplate.Core
5 | WebApiTemplate.Core
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/Core/Customers/Customer.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core.Customers;
2 |
3 | ///
4 | /// Customer entity.
5 | ///
6 | ///
7 | public record Customer(int? Id) : BaseEntity(Id);
8 |
--------------------------------------------------------------------------------
/src/Core/Customers/ICustomerReadRepository.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core.Customers;
2 |
3 | ///
4 | /// repository interface for read operations.
5 | ///
6 | public interface ICustomerReadRepository : IReadRepository { }
7 |
--------------------------------------------------------------------------------
/src/Core/Customers/ICustomerWriteRepository.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core.Customers;
2 |
3 | ///
4 | /// repository interface for write operations.
5 | ///
6 | public interface ICustomerWriteRepository : IWriteRepository { }
7 |
--------------------------------------------------------------------------------
/src/Core/IReadRepository.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core;
2 |
3 | ///
4 | /// Base interface for read repositories.
5 | ///
6 | /// The domain entity type, must inherit from BaseEntity.
7 | public interface IReadRepository
8 | where T : BaseEntity
9 | {
10 | ///
11 | /// Get the entity by its id.
12 | ///
13 | /// The id of the entity to get.
14 | /// The entity, if found, otherwise null.
15 | public Task GetById(int id);
16 | }
17 |
--------------------------------------------------------------------------------
/src/Core/IUnitOfWork.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core;
2 |
3 | ///
4 | /// The Unit of Work pattern interface.
5 | ///
6 | public interface IUnitOfWork : IDisposable, IAsyncDisposable
7 | {
8 | ///
9 | /// Commit the changes made in the unit of work.
10 | ///
11 | /// An optional .
12 | /// An instance of the class.
13 | Task Commit(CancellationToken cancellationToken = default);
14 | }
15 |
--------------------------------------------------------------------------------
/src/Core/IUnitOfWorkFactory.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core;
2 |
3 | ///
4 | /// Unit of work factory interface.
5 | ///
6 | public interface IUnitOfWorkFactory
7 | {
8 | ///
9 | /// Creates a new instance of the unit of work.
10 | ///
11 | /// An optional .
12 | /// An instance of the interface.
13 | Task Create(CancellationToken cancellationToken = default);
14 | }
15 |
--------------------------------------------------------------------------------
/src/Core/IWriteRepository.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core;
2 |
3 | ///
4 | /// Base interface for write repositories.
5 | ///
6 | /// The domain entity type, must inherit from BaseEntity.
7 | public interface IWriteRepository
8 | where T : BaseEntity
9 | {
10 | ///
11 | /// Create a new entity.
12 | ///
13 | /// The new entity to create.
14 | /// The Unit of Work to include the operation in.
15 | /// An instance of the class.
16 | public Task Create(T entity, IUnitOfWork uow);
17 |
18 | ///
19 | /// Update an existing entity.
20 | ///
21 | /// The entity to update.
22 | /// The Unit of Work to include the operation in.
23 | /// An instance of the class.
24 | public Task Update(T entity, IUnitOfWork uow);
25 |
26 | ///
27 | /// Delete an entity by its Id.
28 | ///
29 | /// The id of the identity to delete.
30 | /// The Unit of Work to include the operation in.
31 | /// An instance of the class.
32 | public Task Delete(int id, IUnitOfWork uow);
33 | }
34 |
--------------------------------------------------------------------------------
/src/Core/Nothing.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.Core;
2 |
3 | ///
4 | /// This class should be used instead of void for return types.
5 | /// Taken from: https://github.com/gregoryyoung/nothing/blob/master/Unit/Nothing.cs
6 | ///
7 | public class Nothing
8 | {
9 | ///
10 | /// Singleton instance of the Nothing class.
11 | ///
12 | public static readonly Nothing Instance = new();
13 |
14 | private Nothing() { }
15 |
16 | ///
17 | public override string ToString() => "Nothing";
18 | }
19 |
--------------------------------------------------------------------------------
/src/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | true
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | all
6 | runtime; build; native; contentfiles; analyzers; buildtransitive
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/Infrastructure/Customers/CustomerReadRepository.cs:
--------------------------------------------------------------------------------
1 | using System.Data.Common;
2 | using WebApiTemplate.Core.Customers;
3 | using WebApiTemplate.Infrastructure.Persistence;
4 |
5 | namespace WebApiTemplate.Infrastructure.Customers;
6 |
7 | ///
8 | /// Repository for reading entities.
9 | ///
10 | public class CustomerReadRepository : ReadRepositoryBase, ICustomerReadRepository
11 | {
12 | ///
13 | /// Initializes a new instance of the class.
14 | ///
15 | ///
16 | public CustomerReadRepository(DbDataSource db)
17 | : base(db) { }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Infrastructure/Customers/CustomerWriteRepository.cs:
--------------------------------------------------------------------------------
1 | using WebApiTemplate.Core.Customers;
2 | using WebApiTemplate.Infrastructure.Persistence;
3 |
4 | namespace WebApiTemplate.Infrastructure.Customers;
5 |
6 | ///
7 | /// Repository for writing entities.
8 | ///
9 | public class CustomerWriteRepository : WriteRepositoryBase, ICustomerWriteRepository
10 | {
11 | ///
12 | /// Initializes a new instance of the class.
13 | ///
14 | public CustomerWriteRepository()
15 | : base() { }
16 | }
17 |
--------------------------------------------------------------------------------
/src/Infrastructure/Infrastructure.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WebApiTemplate.Infrastructure
5 | WebApiTemplate.Infrastructure
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/Infrastructure/Persistence/AppDbContext.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore;
2 | using WebApiTemplate.Core.Customers;
3 |
4 | namespace WebApiTemplate.Infrastructure.Persistence;
5 |
6 | ///
7 | /// The application Entity Framework database context.
8 | ///
9 | public class AppDbContext : DbContext
10 | {
11 | ///
12 | /// Gets the customers database set.
13 | ///
14 | public DbSet Customers => Set();
15 |
16 | ///
17 | /// Initializes a new instance of the class.
18 | ///
19 | ///
20 | public AppDbContext(DbContextOptions options)
21 | : base(options) { }
22 | }
23 |
--------------------------------------------------------------------------------
/src/Infrastructure/Persistence/ReadRepositoryBase.cs:
--------------------------------------------------------------------------------
1 | using System.Data.Common;
2 | using Dapper.Contrib.Extensions;
3 | using WebApiTemplate.Core;
4 |
5 | namespace WebApiTemplate.Infrastructure.Persistence;
6 |
7 | ///
8 | /// Base class for read repositories.
9 | ///
10 | /// The database data source.
11 | /// The entity type.
12 | public abstract class ReadRepositoryBase(DbDataSource db) : IReadRepository
13 | where T : BaseEntity
14 | {
15 | ///
16 | /// Gets the entity by its id.
17 | ///
18 | /// The id of the entity to get.
19 | /// The entity with the specified id.
20 | public virtual async Task GetById(int id)
21 | {
22 | await using var conn = await db.OpenConnectionAsync();
23 | return await conn.GetAsync(id);
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Infrastructure/Persistence/RepositoryOptions.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel.DataAnnotations;
2 |
3 | namespace WebApiTemplate.Infrastructure.Persistence;
4 |
5 | ///
6 | /// Options for configuring the repository.
7 | ///
8 | public class RepositoryOptions
9 | {
10 | ///
11 | /// The connection string to use.
12 | ///
13 | [Required]
14 | public string? ConnectionString { get; set; }
15 | }
16 |
--------------------------------------------------------------------------------
/src/Infrastructure/Persistence/UnitOfWork.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using WebApiTemplate.Core;
3 |
4 | namespace WebApiTemplate.Infrastructure.Persistence;
5 |
6 | ///
7 | /// The unit of work implementation based on Entity Framework.
8 | ///
9 | [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don\'t dispose injected")]
10 | public sealed class UnitOfWork(AppDbContext dbContext) : IUnitOfWork
11 | {
12 | internal AppDbContext DbContext { get; } = dbContext;
13 |
14 | ///
15 | /// Commit the changes made in the unit of work.
16 | ///
17 | /// An optional .
18 | /// An instance of the class.
19 | public async Task Commit(CancellationToken cancellationToken = default)
20 | {
21 | await DbContext.SaveChangesAsync(cancellationToken);
22 | return Nothing.Instance;
23 | }
24 |
25 | ///
26 | public void Dispose() => DbContext.Dispose();
27 |
28 | ///
29 | public ValueTask DisposeAsync() => DbContext.DisposeAsync();
30 | }
31 |
--------------------------------------------------------------------------------
/src/Infrastructure/Persistence/UnitOfWorkFactory.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore;
2 | using WebApiTemplate.Core;
3 |
4 | namespace WebApiTemplate.Infrastructure.Persistence;
5 |
6 | ///
7 | /// Unit of work factory implementation based on Entity Framework.
8 | ///
9 | public class UnitOfWorkFactory(IDbContextFactory dbContextFactory)
10 | : IUnitOfWorkFactory
11 | {
12 | ///
13 | /// Creates a new instance of the unit of work.
14 | ///
15 | ///
16 | /// An instance of the UnitOfWork class.
17 | public async Task Create(CancellationToken cancellationToken = default) =>
18 | new UnitOfWork(await dbContextFactory.CreateDbContextAsync(cancellationToken));
19 | }
20 |
--------------------------------------------------------------------------------
/src/Infrastructure/Persistence/WriteRepositoryBase.cs:
--------------------------------------------------------------------------------
1 | using WebApiTemplate.Core;
2 |
3 | namespace WebApiTemplate.Infrastructure.Persistence;
4 |
5 | ///
6 | /// Base class for write repositories.
7 | ///
8 | ///
9 | public abstract class WriteRepositoryBase : IWriteRepository
10 | where T : BaseEntity
11 | {
12 | ///
13 | /// Creates a new entity.
14 | ///
15 | /// The entity to create.
16 | /// The unit of work to use.
17 | /// An instance of .
18 | public virtual Task Create(T entity, IUnitOfWork uow)
19 | {
20 | ((UnitOfWork)uow).DbContext.Add(entity);
21 | return Task.FromResult(Nothing.Instance);
22 | }
23 |
24 | ///
25 | /// Updates an entity.
26 | ///
27 | /// The entity containing the updated values.
28 | /// The unit of work to use.
29 | /// An instance of .
30 | public virtual Task Update(T entity, IUnitOfWork uow)
31 | {
32 | ((UnitOfWork)uow).DbContext.Update(entity);
33 | return Task.FromResult(Nothing.Instance);
34 | }
35 |
36 | ///
37 | /// Deletes an entity by its id.
38 | ///
39 | /// The id of the entity to delete.
40 | /// The unit of work to use.
41 | /// An instance of .
42 | public virtual async Task Delete(int id, IUnitOfWork uow)
43 | {
44 | var dbContext = ((UnitOfWork)uow).DbContext;
45 | var entityToDelete = await dbContext.FindAsync(typeof(T), id);
46 | if (entityToDelete is not null)
47 | {
48 | dbContext.Remove(entityToDelete);
49 | }
50 |
51 | return Nothing.Instance;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/WebApi/AppControllerBase.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using Microsoft.AspNetCore.Mvc;
3 |
4 | namespace WebApiTemplate.WebApi;
5 |
6 | ///
7 | /// Base class for all controllers in the application.
8 | ///
9 | [ApiController]
10 | [Route("api/v1/[controller]")]
11 | public abstract class AppControllerBase(IMediator mediator) : ControllerBase
12 | {
13 | ///
14 | /// The mediator instance.
15 | ///
16 | protected readonly IMediator _mediator = mediator;
17 | }
18 |
--------------------------------------------------------------------------------
/src/WebApi/Customers/CustomersController.cs:
--------------------------------------------------------------------------------
1 | using HumbleMediator;
2 | using Microsoft.AspNetCore.Mvc;
3 | using WebApiTemplate.Application.Customers.Commands;
4 | using WebApiTemplate.Application.Customers.Queries;
5 | using WebApiTemplate.Core;
6 | using WebApiTemplate.Core.Customers;
7 | using WebApiTemplate.WebApi.Customers.Requests;
8 | using WebApiTemplate.WebApi.Customers.Responses;
9 |
10 | namespace WebApiTemplate.WebApi.Customers;
11 |
12 | ///
13 | /// The controller for the Customer entity.
14 | ///
15 | ///
16 | public sealed class CustomersController(IMediator mediator) : AppControllerBase(mediator)
17 | {
18 | ///
19 | /// Creates a new entity.
20 | ///
21 | /// The data for the new entity.
22 | /// A 201 Created response with the ID of the new entity.
23 | [HttpPost]
24 | [Route("")]
25 | public async Task> Create(CreateCustomerRequest request)
26 | {
27 | var id = await _mediator.SendCommand(
28 | new CreateCustomerCommand(CreateCustomerRequest.ToDomainEntity())
29 | );
30 | return CreatedAtAction(nameof(GetById), new { id }, new CustomerCreatedResponse(id));
31 | }
32 |
33 | ///
34 | /// Get an entity by its ID.
35 | ///
36 | /// The ID of the entity to get.
37 | /// A 200 OK response with entity data, or a 404 Not Found response if the entity does not exist.
38 | [HttpGet]
39 | [Route("{id:int}")]
40 | public async Task> GetById(int id)
41 | {
42 | var result = await _mediator.SendQuery(
43 | new GetCustomerByIdQuery(id)
44 | );
45 | if (result is null)
46 | {
47 | return NotFound();
48 | }
49 |
50 | return Ok(result);
51 | }
52 |
53 | ///
54 | /// Updates an entity.
55 | ///
56 | /// The id of the entity to update.
57 | /// The new values for the entity.
58 | /// A 204 No Content response.
59 | [HttpPut]
60 | [Route("{id:int}")]
61 | public async Task Update(int id, UpdateCustomerRequest request)
62 | {
63 | await _mediator.SendCommand(
64 | new UpdateCustomerCommand(id, UpdateCustomerRequest.ToDomainEntity())
65 | );
66 | return NoContent();
67 | }
68 |
69 | ///
70 | /// Deletes an entity by its ID.
71 | ///
72 | /// The id of the entity to delete.
73 | /// A 204 No Content response.
74 | [HttpDelete]
75 | [Route("{id:int}")]
76 | public async Task Delete(int id)
77 | {
78 | await _mediator.SendCommand(new DeleteCustomerCommand(id));
79 | return NoContent();
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/WebApi/Customers/Requests/CreateCustomerRequest.cs:
--------------------------------------------------------------------------------
1 | using WebApiTemplate.Core.Customers;
2 |
3 | namespace WebApiTemplate.WebApi.Customers.Requests;
4 |
5 | ///
6 | /// Request entity for creating a customer.
7 | ///
8 | public class CreateCustomerRequest
9 | {
10 | ///
11 | /// Maps the request to a domain entity.
12 | ///
13 | /// The domain entity.
14 | public static Customer ToDomainEntity() => new(null);
15 | }
16 |
--------------------------------------------------------------------------------
/src/WebApi/Customers/Requests/UpdateCustomerRequest.cs:
--------------------------------------------------------------------------------
1 | using WebApiTemplate.Core.Customers;
2 |
3 | namespace WebApiTemplate.WebApi.Customers.Requests;
4 |
5 | ///
6 | /// Request entity for updating a customer.
7 | ///
8 | public class UpdateCustomerRequest
9 | {
10 | ///
11 | /// Maps the request to a domain entity.
12 | ///
13 | /// The domain entity.
14 | public static Customer ToDomainEntity() => new(null);
15 | }
16 |
--------------------------------------------------------------------------------
/src/WebApi/Customers/Responses/CustomerCreatedResponse.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.WebApi.Customers.Responses;
2 |
3 | ///
4 | /// Response entity returned to the client after the creation of a Customer entity.
5 | ///
6 | /// The ID of the created Customer entity.
7 | public record CustomerCreatedResponse(int Id);
8 |
--------------------------------------------------------------------------------
/src/WebApi/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
2 | WORKDIR /build
3 |
4 | # copy csproj and restore as distinct layers for caching
5 | COPY src/Api/Api.csproj ./src/Api/Api.csproj
6 | COPY src/Core/Core.csproj ./src/Core/Core.csproj
7 | COPY src/Application/Application.csproj ./src/Application/Application.csproj
8 | COPY src/Infrastructure/Infrastructure.csproj ./src/Infrastructure/Infrastructure.csproj
9 | COPY Directory.Build.props .
10 | RUN dotnet restore -r linux-x64 "./src/Api/Api.csproj"
11 |
12 | # copy and publish app and libraries
13 | COPY src/Api/ ./src/Api/
14 | COPY src/Core/ ./src/Core/
15 | COPY src/Application/ ./src/Application/
16 | COPY src/Infrastructure/ ./src/Infrastructure/
17 | RUN dotnet publish -c Release --no-self-contained -r linux-x64 -o /app "./src/Api/Api.csproj"
18 |
19 | # final stage/image
20 | FROM mcr.microsoft.com/dotnet/aspnet:8.0
21 | ENV ASPNETCORE_URLS=http://+:5000
22 | EXPOSE 5000
23 | WORKDIR /app
24 | COPY --from=build /app .
25 | USER app
26 | ENTRYPOINT ["dotnet", "WebApiTemplate.WebApi.dll"]
27 |
--------------------------------------------------------------------------------
/src/WebApi/Program.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using FluentValidation;
3 | using HumbleMediator;
4 | using Microsoft.EntityFrameworkCore;
5 | using Npgsql;
6 | using Serilog;
7 | using Serilog.Events;
8 | using SimpleInjector;
9 | using WebApiTemplate.Application;
10 | using WebApiTemplate.Application.Customers.Commands;
11 | using WebApiTemplate.Application.Customers.Queries;
12 | using WebApiTemplate.Application.Logging;
13 | using WebApiTemplate.Application.Validation;
14 | using WebApiTemplate.Core;
15 | using WebApiTemplate.Core.Customers;
16 | using WebApiTemplate.Infrastructure.Customers;
17 | using WebApiTemplate.Infrastructure.Persistence;
18 |
19 | Log.Logger = new LoggerConfiguration()
20 | .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
21 | .Enrich.FromLogContext()
22 | .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture)
23 | .CreateLogger();
24 |
25 | try
26 | {
27 | Log.Information("Starting web host");
28 |
29 | var builder = WebApplication.CreateBuilder(args);
30 | builder.Host.UseSerilog(); // replace built-in logging with Serilog
31 |
32 | // Add services to the container.
33 | builder.Services.AddControllers();
34 |
35 | // swagger
36 | builder.Services.AddEndpointsApiExplorer();
37 | builder.Services.AddSwaggerGen();
38 |
39 | builder.Services.AddHealthChecks().AddDbContextCheck();
40 |
41 | // persistence
42 | var connString =
43 | builder.Configuration.GetConnectionString("Default")
44 | #pragma warning disable CA2208
45 | ?? throw new ArgumentNullException("connectionString");
46 | #pragma warning restore CA2208
47 | builder.Services.AddNpgsqlDataSource(connString);
48 |
49 | Action dbConfigure = (sp, options) =>
50 | options.UseNpgsql(sp.GetRequiredService()).UseSnakeCaseNamingConvention();
51 | builder.Services.AddDbContext(dbConfigure, ServiceLifetime.Singleton);
52 | builder.Services.AddPooledDbContextFactory(dbConfigure);
53 | builder.Services.AddDistributedMemoryCache();
54 |
55 | // SimpleInjector
56 | var container = Container;
57 | container.Options.DefaultLifestyle = Lifestyle.Singleton;
58 | builder.Services.AddSimpleInjector(
59 | container,
60 | options => options.AddAspNetCore().AddControllerActivation()
61 | );
62 |
63 | container.Register();
64 | container.Register();
65 | container.Register();
66 |
67 | // validators
68 | container.Collection.Register(
69 | typeof(IValidator<>),
70 | typeof(GetCustomerByIdQueryValidator).Assembly
71 | );
72 |
73 | // mediator
74 | container.Register(() => new Mediator(container.GetInstance));
75 |
76 | // mediator handlers
77 | container.Register(typeof(ICommandHandler<,>), typeof(CreateCustomerCommandHandler).Assembly);
78 | container.Register(typeof(IQueryHandler<,>), typeof(GetCustomerByIdQueryHandler).Assembly);
79 |
80 | // mediator handlers decorators - queries pipeline
81 | container.RegisterDecorator(
82 | typeof(IQueryHandler<,>),
83 | typeof(QueryHandlerValidationDecorator<,>)
84 | );
85 | container.RegisterDecorator(typeof(IQueryHandler<,>), typeof(QueryHandlerCachingDecorator<,>));
86 | container.RegisterDecorator(typeof(IQueryHandler<,>), typeof(QueryHandlerLoggingDecorator<,>));
87 |
88 | // mediator handlers decorators - commands pipeline
89 | container.RegisterDecorator(
90 | typeof(ICommandHandler<,>),
91 | typeof(CommandHandlerValidationDecorator<,>)
92 | );
93 | container.RegisterDecorator(
94 | typeof(ICommandHandler<,>),
95 | typeof(CommandHandlerLoggingDecorator<,>)
96 | );
97 |
98 | var app = builder.Build();
99 |
100 | app.Services.UseSimpleInjector(container);
101 |
102 | // Apply pending EF Core migrations automatically in development mode.
103 | // To do that in production, especially in multi-instance scenarios, you need
104 | // to make sure that migrations are applied as a separate deploy step to prevent data corruption.
105 | if (app.Environment.IsDevelopment())
106 | {
107 | var dbContextFactory = app.Services.GetRequiredService>();
108 | var dbContext = await dbContextFactory.CreateDbContextAsync();
109 | if (dbContext.Database.IsRelational())
110 | {
111 | // It will throw if the db is not relational
112 | await dbContext.Database.MigrateAsync();
113 | }
114 | }
115 |
116 | app.UseSerilogRequestLogging();
117 |
118 | // Configure the HTTP request pipeline.
119 | if (app.Environment.IsDevelopment())
120 | {
121 | app.UseSwagger();
122 | app.UseSwaggerUI();
123 | }
124 |
125 | if (!app.Environment.IsDevelopment())
126 | {
127 | app.UseHttpsRedirection();
128 | }
129 |
130 | app.MapHealthChecks("/health");
131 |
132 | app.UseAuthorization();
133 | app.MapControllers();
134 |
135 | container.Verify();
136 |
137 | app.Run();
138 | return 0;
139 | }
140 | catch (Exception ex)
141 | {
142 | Log.Fatal(ex, "Host terminated unexpectedly");
143 | return 1;
144 | }
145 | finally
146 | {
147 | Log.CloseAndFlush();
148 | }
149 |
150 | ///
151 | /// The Program class. This is added support accessing the instance.
152 | ///
153 | public partial class Program
154 | {
155 | ///
156 | /// The instance.
157 | ///
158 | public static readonly Container Container = new();
159 | }
160 |
--------------------------------------------------------------------------------
/src/WebApi/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/launchsettings.json",
3 | "profiles": {
4 | "WebApiTemplate.WebApi": {
5 | "commandName": "Project",
6 | "dotnetRunMessages": true,
7 | "launchBrowser": true,
8 | "launchUrl": "swagger",
9 | "applicationUrl": "http://localhost:5000",
10 | "environmentVariables": {
11 | "ASPNETCORE_ENVIRONMENT": "Development"
12 | }
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/WebApi/WebApi.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WebApiTemplate.WebApi
5 | WebApiTemplate.WebApi
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/WebApi/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "ConnectionStrings": {
3 | "Default": ""
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/WebApi/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "AllowedHosts": "*"
3 | }
4 |
--------------------------------------------------------------------------------
/tests/Directory.Build.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | false
5 | false
6 | true
7 | IDE0005
8 |
9 |
10 |
--------------------------------------------------------------------------------
/tests/Directory.Packages.props:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/AppTestCollection.cs:
--------------------------------------------------------------------------------
1 | using Xunit;
2 |
3 | namespace WebApiTemplate.IntegrationTests;
4 |
5 | [CollectionDefinition(nameof(AppTestCollection))]
6 | public class AppTestCollection : ICollectionFixture { }
7 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/AppWebApplicationFactory.cs:
--------------------------------------------------------------------------------
1 | // ReSharper disable ClassNeverInstantiated.Global
2 |
3 | #pragma warning disable CS8618
4 |
5 | using System.Data.Common;
6 | using Microsoft.AspNetCore.Mvc.Testing;
7 | using Microsoft.EntityFrameworkCore;
8 | using Npgsql;
9 | using Respawn;
10 | using Respawn.Graph;
11 | using Testcontainers.PostgreSql;
12 | using WebApiTemplate.Infrastructure.Persistence;
13 | using Xunit;
14 |
15 | namespace WebApiTemplate.IntegrationTests;
16 |
17 | public class AppWebApplicationFactory : WebApplicationFactory, IAsyncLifetime
18 | {
19 | private readonly PostgreSqlContainer _dbContainer;
20 | private Respawner _respawner;
21 |
22 | internal AppWebApplicationFactory()
23 | {
24 | _dbContainer = new PostgreSqlBuilder()
25 | .WithDatabase(Constants.TestPostgresDatabase)
26 | .WithUsername(Constants.TestPostgresUsername)
27 | .WithPassword(Constants.TestPostgresPassword)
28 | .WithExposedPort(Constants.TestPostgresPort)
29 | .WithPortBinding(Constants.TestPostgresPort)
30 | .Build();
31 | }
32 |
33 | public async Task InitializeAsync()
34 | {
35 | await _dbContainer.StartAsync();
36 |
37 | var contextFactory = this.Services.GetRequiredService>();
38 | await using var context = await contextFactory.CreateDbContextAsync();
39 | await using var connection = context.Database.GetDbConnection();
40 | await connection.OpenAsync();
41 | _respawner = await Respawner.CreateAsync(
42 | connection,
43 | new RespawnerOptions
44 | {
45 | DbAdapter = DbAdapter.Postgres,
46 | TablesToIgnore = new Table[] { "__EFMigrationsHistory" },
47 | }
48 | );
49 | }
50 |
51 | protected override void ConfigureWebHost(IWebHostBuilder builder)
52 | {
53 | builder.ConfigureServices(services =>
54 | {
55 | var typesToRemove = new[]
56 | {
57 | typeof(DbContextOptions),
58 | typeof(DbContextOptions),
59 | typeof(IDbContextFactory),
60 | typeof(NpgsqlDataSource),
61 | typeof(DbConnection),
62 | typeof(DbDataSource),
63 | typeof(NpgsqlConnection),
64 | };
65 |
66 | var toRemove = services.Where(e => typesToRemove.Contains(e.ServiceType)).ToList();
67 | foreach (var descriptor in toRemove)
68 | {
69 | services.Remove(descriptor);
70 | }
71 |
72 | services.AddNpgsqlDataSource(_dbContainer.GetConnectionString());
73 | services.AddPooledDbContextFactory(
74 | (sp, options) =>
75 | options
76 | .UseNpgsql(sp.GetRequiredService())
77 | .UseSnakeCaseNamingConvention()
78 | );
79 |
80 | Program.Container.Options.AllowOverridingRegistrations = true;
81 | });
82 | }
83 |
84 | public async Task ResetDatabase()
85 | {
86 | var contextFactory = this.Services.GetRequiredService>();
87 | await using var context = await contextFactory.CreateDbContextAsync();
88 | await using var connection = context.Database.GetDbConnection();
89 | await connection.OpenAsync();
90 | await _respawner.ResetAsync(connection);
91 | }
92 |
93 | public new async Task DisposeAsync()
94 | {
95 | await _dbContainer.StopAsync();
96 | await _dbContainer.DisposeAsync();
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/BaseTestClass.cs:
--------------------------------------------------------------------------------
1 | using Xunit;
2 |
3 | namespace WebApiTemplate.IntegrationTests;
4 |
5 | [Trait("Category", "Integration")]
6 | [Collection(nameof(AppTestCollection))]
7 | public abstract class BaseTestClass(AppWebApplicationFactory factory)
8 | {
9 | protected readonly AppWebApplicationFactory _factory = factory;
10 | }
11 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/Constants.cs:
--------------------------------------------------------------------------------
1 | namespace WebApiTemplate.IntegrationTests;
2 |
3 | public static class Constants
4 | {
5 | public const string TestPostgresDatabase = "testpostgresdatabase";
6 |
7 | public const string TestPostgresUsername = "testpostgresuser";
8 |
9 | public const string TestPostgresPassword = "testpostgrespassword";
10 |
11 | public const int TestPostgresPort = 10283;
12 | }
13 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/Customers/Controllers/CreateTests.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using FluentAssertions;
3 | using Microsoft.EntityFrameworkCore;
4 | using WebApiTemplate.Infrastructure.Persistence;
5 | using WebApiTemplate.WebApi.Customers.Requests;
6 | using Xunit;
7 |
8 | namespace WebApiTemplate.IntegrationTests.Customers.Controllers;
9 |
10 | public class CreateTests(AppWebApplicationFactory factory) : BaseTestClass(factory)
11 | {
12 | [Fact]
13 | public async Task WithValidRequestShouldCreateCorrectly()
14 | {
15 | await _factory.ResetDatabase();
16 |
17 | using var client = _factory.CreateClient();
18 | var request = new CreateCustomerRequest();
19 | using var response = await client.PostAsJsonAsync($"/api/customers", request);
20 |
21 | response.StatusCode.Should().Be(HttpStatusCode.Created);
22 |
23 | var contextFactory = _factory.Services.GetRequiredService<
24 | IDbContextFactory
25 | >();
26 | await using var context = await contextFactory.CreateDbContextAsync();
27 |
28 | var result = await context.Customers.SingleOrDefaultAsync();
29 | result.Should().NotBeNull();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/Customers/Controllers/DeleteTests.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using FluentAssertions;
3 | using Microsoft.EntityFrameworkCore;
4 | using WebApiTemplate.Infrastructure.Persistence;
5 | using Xunit;
6 |
7 | namespace WebApiTemplate.IntegrationTests.Customers.Controllers;
8 |
9 | public class DeleteTests(AppWebApplicationFactory factory) : BaseTestClass(factory)
10 | {
11 | [Fact]
12 | public async Task WithCustomerPresentDeletesCorrectly()
13 | {
14 | await _factory.ResetDatabase();
15 | const int id = 1278;
16 |
17 | var contextFactory = _factory.Services.GetRequiredService<
18 | IDbContextFactory
19 | >();
20 | await using var context = await contextFactory.CreateDbContextAsync();
21 | context.Customers.Add(new Core.Customers.Customer(id));
22 | await context.SaveChangesAsync();
23 |
24 | using var client = _factory.CreateClient();
25 | using var response = await client.DeleteAsync($"/api/customers/{id}");
26 |
27 | response.StatusCode.Should().Be(HttpStatusCode.NoContent);
28 | var allCustomers = await context.Customers.ToListAsync();
29 | allCustomers.Should().BeEmpty();
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/Customers/Controllers/GetByIdTests.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using FluentAssertions;
3 | using Microsoft.EntityFrameworkCore;
4 | using WebApiTemplate.Infrastructure.Persistence;
5 | using Xunit;
6 |
7 | namespace WebApiTemplate.IntegrationTests.Customers.Controllers;
8 |
9 | public class GetByIdTests(AppWebApplicationFactory factory) : BaseTestClass(factory)
10 | {
11 | [Fact]
12 | public async Task WithCustomerPresentInDbShouldReturnCorrectly()
13 | {
14 | await _factory.ResetDatabase();
15 | const int id = 23;
16 |
17 | var contextFactory = _factory.Services.GetRequiredService<
18 | IDbContextFactory
19 | >();
20 | await using var context = await contextFactory.CreateDbContextAsync();
21 | context.Customers.Add(new Core.Customers.Customer(id));
22 | await context.SaveChangesAsync();
23 |
24 | using var client = _factory.CreateClient();
25 | using var response = await client.GetAsync($"/api/customers/{id}");
26 |
27 | response.StatusCode.Should().Be(HttpStatusCode.OK);
28 |
29 | var customer = await response.Content.ReadFromJsonAsync();
30 | customer.Should().NotBeNull();
31 | customer!.Id.Should().Be(id);
32 | }
33 |
34 | [Fact]
35 | public async Task WithNoCustomerPresentInDbShouldReturnNotFound()
36 | {
37 | await _factory.ResetDatabase();
38 | const int id = 483930;
39 |
40 | using var client = _factory.CreateClient();
41 | using var response = await client.GetAsync($"/api/customers/{id}");
42 |
43 | response.StatusCode.Should().Be(HttpStatusCode.NotFound);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/IntegrationTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WebApiTemplate.IntegrationTests
5 | WebApiTemplate.IntegrationTests
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/tests/IntegrationTests/xunit.runner.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
3 | "parallelizeTestCollections": false
4 | }
5 |
--------------------------------------------------------------------------------
/tests/UnitTests/Customers/CreateCustomerCommandHandler/HandleTests.cs:
--------------------------------------------------------------------------------
1 | using NSubstitute;
2 | using WebApiTemplate.Application.Customers.Commands;
3 | using WebApiTemplate.Core;
4 | using WebApiTemplate.Core.Customers;
5 | using Xunit;
6 |
7 | namespace WebApiTemplate.UnitTests.Customers.CreateCustomerCommandHandler;
8 |
9 | public class HandleTests
10 | {
11 | [Fact]
12 | [System.Diagnostics.CodeAnalysis.SuppressMessage(
13 | "IDisposableAnalyzers.Correctness",
14 | "IDISP004:Don't ignore created IDisposable",
15 | Justification = "The IDisposable is not actually created in the test."
16 | )]
17 | public async Task WithValidRequestShouldCallRepository()
18 | {
19 | // Arrange
20 | var mockWriteRepository = Substitute.For();
21 | mockWriteRepository
22 | .Create(Arg.Any(), Arg.Any())
23 | .Returns(Nothing.Instance);
24 |
25 | var mockUow = Substitute.For();
26 |
27 | var mockUowFactory = Substitute.For();
28 | mockUowFactory.Create().ReturnsForAnyArgs(mockUow);
29 |
30 | var sut = new Application.Customers.Commands.CreateCustomerCommandHandler(
31 | mockUowFactory,
32 | mockWriteRepository
33 | );
34 | var newEntity = new Customer(13452);
35 |
36 | // Act
37 | await sut.Handle(new CreateCustomerCommand(newEntity));
38 |
39 | // Assert
40 | await mockWriteRepository.Received(1).Create(Arg.Is(newEntity), Arg.Is(mockUow));
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/tests/UnitTests/Customers/GetCustomerByIdQueryHandler/HandleTests.cs:
--------------------------------------------------------------------------------
1 | using FluentAssertions;
2 | using NSubstitute;
3 | using WebApiTemplate.Application.Customers.Queries;
4 | using WebApiTemplate.Core.Customers;
5 | using Xunit;
6 |
7 | namespace WebApiTemplate.UnitTests.Customers.GetCustomerByIdQueryHandler;
8 |
9 | public class HandleTests
10 | {
11 | [Fact]
12 | public async Task WithValidRequestShouldCallRepository()
13 | {
14 | // Arrange
15 | var expected = new Customer(1);
16 | var mock = Substitute.For();
17 | mock.GetById(default).ReturnsForAnyArgs(expected);
18 |
19 | var sut = new Application.Customers.Queries.GetCustomerByIdQueryHandler(mock);
20 |
21 | // Act
22 | var result = await sut.Handle(new GetCustomerByIdQuery(1));
23 |
24 | // Assert
25 | result.Should().BeEquivalentTo(expected);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/UnitTests/UnitTests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WebApiTemplate.UnitTests
5 | WebApiTemplate.UnitTests
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/tests/UnitTests/xunit.runner.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
3 | "parallelizeTestCollections": true
4 | }
5 |
--------------------------------------------------------------------------------