Provides general abstractions, algorithms, and data structures for .NET
Light.SharedCore is compiled against .NET Standard 2.0 and 2.1 and thus supports all major platforms like .NET and .NET Framework, Mono, Xamarin, UWP, or Unity.
Light.SharedCore is available as a NuGet package and can be installed via:
- Package Reference in csproj:
<PackageReference Include="Light.SharedCore" Version="2.0.0" />
- dotnet CLI:
dotnet add package Light.SharedCore
- Visual Studio Package Manager Console:
Install-Package Light.SharedCore
Light.SharedCore offers you four base classes for entities. These are Int32Entity
, Int64Entity
, GuidEntity
, and StringEntity
. All of them offer an Id
property of the corresponding type which is immutable by default. Also, all these classes implement IEntity<T>
(this interface is part of Light.SharedCore) and IEquatable<T>
for you (two instances are considered equal when they have equal ID values). These base classes are specifically tailored to be used with Object-Relational Mappers or serialization frameworks. They are immutable by default, although you can use the IMutableEntity<T>
interface to change the ID after initialization.
A class that derives from these entities could look like this:
public sealed class Address : Int32Entity
{
// Id property is not needed, it comes with the base class
public string Street { get; set; } = string.Empty;
public string ZipCode { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
}
Your class can then be instantiated like so:
var address = new Address
{
Id = 1, // Or leave it out when the ID is generated by the database
Street = "123 Lane Street",
ZipCode = "49230",
Location = "London"
};
The base classes also offer a parameterized constructor, so you could also make your class immutable via constructor injection (check if your ORM and serialization framework supports this - Entity Framework Core and System.Text.Json do support this, for example):
public sealed class Address : Int32Entity
{
public Address(int id, string street, string zipCode, string location)
: base(id)
{
Street = street;
ZipCode = zipCode;
Location = location;
}
public string Street { get; }
public string ZipCode { get; }
public string Location { get; }
}
By default, the Int32Entity
and Int64Entity
base classes will only allow IDs that are greater or equal to 1.
// This statement will throw because Int32Entity and Int64Entity
// only allow positive IDs by default
var address = new Address { Id = 0 };
You can customize this behavior by using the static AllowIdZero
and AllowNegativeIds
properties:
Int32Entity.AllowIdZero = true;
var address = new Address { Id = 0 };
There is also a handy AllowZeroAndNegativeIds
method to set both AllowIdZero
and AllowNegativeIds
to true with one call.
BE CAREFUL: all entities that derive from
Int32Entity
are affected by setting theAllowIdZero
property or theAllowNegativeIds
property totrue
. If you want to limit these settings to a specific type, you should derive fromInt32Entity<T>
, like so:
public sealed class Address : Int32Entity<Address>
{
// Members omitted for brevity's sake
}
You can then e.g. only allow zero as a valid ID for addresses:
// other entities are not affected, because they do not derive from Int32Entity<Address>
Address.AllowIdZero = true;
var address = new Address { Id = 0 };
var contact = new Contact { Id = 0 }; // This would throw
Similarly to the other types, you can derive from GuidEntity
or GuidEntity<T>
:
public sealed class Bill : GuidEntity
{
// Id property is not needed, it comes with the base class
public decimal AmountInDollar {get; init; }
}
By default, GuidEntity
does not allow empty GUIDs:
// The next statement will throw
var bill = new Bill { Id = Guid.EmptyGuid };
Similarly to other entity base classes, you can change that by setting the AllowEmptyGuid
static property:
GuidEntity.AllowEmptyGuids = true;
var bill = new Bill { Id = Guid.EmptyGuid }; // This does not throw
As with AllowIdZero
and AllowNegativeIds
, the above code would affect all entities deriving from GuidEntity
. To limit the effect to a single type, you should derive from GuidEntity<T>
.
The string entity has the same basic functionality as the other entity base classes. The IDs that are passed to it are validated with the following rules:
- The string must not be null, empty, or contain only white space
- It must be trimmed, i.e. the first and last character must not be white space
- It must have a maximum length of 200 characters
You can customize this behavior by supplying a delegate to the static ValidateId
property. As always, if you want to limit this to one entity type, consider deriving from StringEntity<T>
(instead of just StringEntity
).
Furthermore, by default, an entity operates in case-sensitive mode (to be precise: StringComparison.Ordinal
). You can change this mode by setting the static ComparisonMode
property to another value of the StringComparison
enum. As always: if you want to limit this to certain entity types, consider deriving from StringEntity<T>
.
BE CAREFUL: you should only change the comparison mode at the beginning of your application (in the composition root) before any of the entities are instantiated. Otherwise, subtle bugs can start to occur (e.g. when the ID is already used as a key in a dictionary), because the
Equals
andGetHashCode
implementation rely on theComparisonMode
value.
The default value for Id
for a string entity is null
. You can change this behavior by using the static IsDefaultValueNull
property whose default value is true
.
By default, all ID properties of the entity base classes are immutable. However, there is a back door that you can use to change the ID after the entity is already fully initialized. The usual scenario where this is necessary is when the ID is created by a database so that the ID is only available after an I/O call:
var address = new Address
{
Street = "123 Lane Street",
ZipCode = "49230",
Location = "London"
};
var idOfNewAddress = await session.InsertAsync(address);
await session.SaveChangesAsync();
address.ToMutable().SetId(idOfNewAddress); // This will set the ID after initialization
To change the ID after initialization, simply call entity.ToMutable().SetId(newId)
. ToMutable
is an extension method which will not pollute the public API of your entities.
BE CAREFUL: you must not change the ID of an entity when it is already supposed to be immutable. This might lead to subtle bugs e.g. when the ID is used as a key in a dictionary.
.NET already offers many TryParse
methods when it comes to parsing text to floating point values, but all of them have the issue that they interpret points and commas in a dedicated way (either as decimal sign or as thousand-delimiter sign, depending on the current or provided CultureInfo
).
But often (and especially in a German context), commas and points might be mixed up, e.g. when users enter text into a text box, or when some IoT devices numbers in the German format, but others in the English format.
You can use the DoubleParser.TryParse
method which analyses the input string for points and commas and then chooses either the invariant culture or the German culture to parse the string, depending on the number of points and commas and where they are placed. Check out the following code:
DoubleParser.TryParse("15.0", out var value); // value = 15.0
DoubleParser.TryParse("15,0", out var value); // value = 15.0
DoubleParser.TryParse("200,575.833", out var value); // value = 200575.833
DoubleParser.TryParse("200.575,833", out var value); // value = 200575.833
BE CAREFUL: if you have a number with only a single thousand-delimiter sign (i.e. no decimal sign), this number will not be parsed correctly. The thousand-delimiter sign will be interpreted as the decimal sign. We recognize that this scenario is rare, as especially human input will most likely never use the thousand-delimiter sign. Howevery, if this scenario applies, then please use the .NET
TryParse
methods and specify the correct culture info by yourself.
Light.SharedCore also offers you the FloatParser
and the DecimalParser
. Furthermore, the .NET Standard 2.1 version of this library has support for ReadOnlySpan<char>
.
Light.SharedCore provides the IClock
interface that abstracts calls to DateTime.Now
and DateTime.UtcNow
. This is usually required when testing your code, and you want to supply dedicated DateTime
values to better control your tests. IClock
has a method called GetTime
that you can use to obtain the current time stamp.
There are three implementations for IClock
:
UtcClock
will returnDateTime.UtcNow
when callingGetTime
. This should be the default clock that you use as the resulting value is unambiguous.LocalClock
will return the local time. Be aware that this might lead to ambiguous time stamps, e.g. when a change from standard time to daylight saving time happens.TestClock
can be used in unit test scenarios to control the time programmatically.
You typically register the clock as a singleton with the DI container:
services.AddUtcClock();
The clock can then be injected into a client:
public sealed class UdpateJob
{
public UpdateJob(IClock clock, INotificationService notificationService)
{
Clock = clock;
NotificationService = notificationService;
}
private IClock Clock { get; }
private INotificationService NotificationService { get; }
public async Task ExecuteAsync()
{
var now = Clock.GetTime();
// Do something here
var finished = Clock.GetTime();
if ((finished - now) >= TimeSpan.FromMinutes(2))
await NotificationService.CreateMessage("The update took unusually long - please check the log files for irregularities.");
}
}
The usage of IClock
in your production code lets us now write the tests way easier:
public sealed class UpdateJobTests
{
[Fact]
public async Task CreateNotificationOnLongExecutionTime()
{
var initialTime = DateTime.UtcNow;
var secondTime = initialTime.AddMinutes(2);
var testClock = new TestClock(initialTime, secondTime);
var notificationService = new NotificationServiceMock();
var job = new UpdateJob(testClock, notificationService);
await job.ExecuteAsync();
notificationService.CreateMessageMustHaveBeenCalled();
}
}
In the example above, two DateTime
instances are created, where the second one is two minutes later than the initial one. They are passed to the test clock which will return them on subsequent calls to GetTime
. This allows us to easily check if the notification service is called properly by our job implementation.
TestClock
also provides you with a AdvanceTime
method that will change the current time. This can be used in scenarios where flow control returns to the test method in between calls to GetTime
.
Prefer UTC time stamps, especially in services and when saving date and time values. They are unambiguous, especially when it comes to changes in daylight saving time or to political decisions. You can convert your UTC time stamp to local time in the UI layer.
This package offers interfaces for accessing databases. Both the IAsyncSession
and ISession
interfaces represent the Unit-of-Work Design Pattern. We strongly recommend to use IAsyncSession
by default as all database I/O should be executed in an asynchronous fashion to avoid threads being blocked during database queries. This is especially important when you try to scale service apps. Incoming requests will usually be handled by executing code on the .NET Thread Pool (e.g. in ASP.NET Core) which in turn will create new threads when it sees that its worker threads are blocked. With a high number of concurrent requests, you might end up in a situation where your service app responds really slowly because of all the overhead of new threads being created and the context switches between them (thread starvation).
However, some data access libraries do not support asynchronous queries. As of June 2024, e.g. SQLite did not override the asynchronous methods of ADO.NET - all calls will always be executed synchronously (even when you call the async APIs, like DbConnection.OpenAsync
). You can resort to ISession
in these circumstances. Please make sure that your ADO.NET provider overrides async methods properly.
There is also an IAsyncReadOnlySession
interface that derives from both IDisposable
and IAsyncDisposable
. It can be used to create abstractions for sessions that only read data and do not require an explicit transaction.
If you need to support several transactions during a database session, then use the IAsyncTransactionalSession
(or ITransactionalSession
) interfaces. Instead of a SaveChangesAsync
method, you can use this session type to manually begin transactions by calling BeginTransactionAsync
. You can then save your changes by committing the transaction. Please be aware that you should not nest transaction, i.e. you should not call BeginTransactionAsync
again while you still have an existing transaction in your current scope.