-
Notifications
You must be signed in to change notification settings - Fork 65
Business Logic
Business Logic is executable code that implements your business rules, by transforming and manipulating application data.
- Isolate business logic from rendering logic, user-interface logic, and data access logic
- Formally define the business operations that users can perform
- Allow most user operations to be performed in parallel.
- Allow core business logic to be shared between the two frontends
- Allow for business logic to be tested independently from other layers of the application
At the business level, MODiX is almost entirely a reactionary application. It reacts to two main types of external stimuli: HTTP Requests and Discord Gateway messages. There is also a very small handful of internally-driven stimuli, generated by the use of timers. This is where parallelism comes into play. MODiX processes such "external stimuli" in parallel to other "external stimuli", but within the context of a single "external stimulus" all processing is done in a sequential, albeit asynchronous, manner.
MODiX operates, asynchronously, under the umbrella of the ThreadPool
, which leverages just a few physical threads, optimized based on available hardware, to execute any arbitrary number of "logical threads" in parallel to each other. A "logical thread" consists of async
or otherwise Task
-based method call stacks, chained together by use of await
. Although they do not execute synchronously in a literal sense, they behave as if they do, in a logical sense. Each asynchronous chunk of a "logical thread" is guaranteed by .NET and the ThreadPool
to execute in sequence, and can only be pre-empted by other physical or logical threads.
The other mechanism that makes parallelism possible (dare I say, rather easy) is the concept of scoping provided by Microsoft's Dependency Injection model. Scoping allows for some injected dependencies to be instantiated many times, as needed based on context, rather than just once. This is controlled by the creation of IServiceScope
s through the IoC container (IServiceProvider
). Each IServiceScope
gets its own separate IServiceProvider
that shares some service instances among all other providers (Singleton
services), but also will create its own instances that exist only within that scope (Scoped
services). These services then also get disposed when the scope itself is disposed. This is most easily understood by looking at ASP.NET Core, which handles scoping internally, by creating a unique IServiceScope
for each HTTP request that it receives. Services within that scope are used to process that HTTP request, and when processing is complete, that scope is disposed. Thus, any particular HTTP request can be processed independently of other requests, because it has its own set of service instances to work with, that are independent of the service instances of other requests.
MODiX follows this same pattern for handling stimuli from the Discord Gateway. Messages received over the Gateway connection are, conceptually, very similar to HTTP requests received by Kestrel, and can be handled the same way. Discord.NET is used to process raw messages from the Gateway connection and maintain its in-memory caches of guilds, roles, users, messages, etc. and then notifies MODiX about the received message, through an event
. MODiX then creates both a new "logical thread" and a new IServiceScope
for processing this message, in the same as that ASP.NET Core creates a new "logical thread" and a new IServiceScope
for processing each HTTP request. This is accomplished, specifically, by Modix.Services.Core.DiscordSocketListeningBehavior
and Modix.Common.Messaging.MessageDispatcher
.
Note that this pattern is actually EXTREMELY important to MODiX's reliability. Discord.NET, in order to maintain its in-memory caches, processes all messages received over the Gateway connection sequentially, rather than in parallel. However its API allows for subscribers to events
upon DiscordSocketClient
to perform Task
-based asynchronous work. This might reasonably imply to many that Discord.NET can continue processing Gateway messages while that event handler is suspended, but THIS IS NOT THE CASE. Discord.NET WILL NOT process any Gateway messages until every Task
returned by event
handlers has completed. I.E. the "logical thread" for processing Gateway messages will "logically block", which is particularly bad because this also means that Discord.NET will not be able to send heartbeat messages to Discord, to keep the conneciton alive. If the gateway thread is blocked for too long, the connection will be closed by Discord, and MODiX will automatically shut down. MODiX's approach to parallelism, in particular the fact that MODiX processes every DiscordSocketClient
event synchronously, by simply spawning off a new logical thread, prevents MODiX Business Logic from blocking the Gateway thread.
E.G.
public class MessageDispatcher
: IMessageDispatcher
{
...
public void Dispatch<TNotification>(
Notification notification)
where TNotification : notnull, INotification
{
if (notification == null)
throw new ArgumentNullException(nameof(notification));
#pragma warning disable CS4014
DispatchAsync(notification);
#pragma warning restore CS4014
}
...
internal async Task DispatchAsync<TNotification>(
TNotification notification)
where TNotification : notnull, INotification
{
try
{
using (var serviceScope = ServiceScopeFactory.CreateScope())
{
foreach (var handler in serviceScope.ServiceProvider.GetServices<INotificationHandler<TNotification>>())
{
try
{
await handler.HandleNotificationAsync(notification);
}
catch (Exception ex)
{
Log.Error(ex, "An unexpected error occurred within a handler for a dispatched message: {notification}", notification);
}
}
}
}
catch (Exception ex)
{
Log.Error(ex, "An unexpected error occurred while dispatching a notification: {notification}", notification);
}
}
...
}
A service is, simply, a container for business methods. Generally, services should be stateless, retrieving all relevant state either through parameters, or from injected dependencies. Services also allow for similar or closely related business methods to be grouped together, and even sometimes to share implementation details.
Services should always be defined through an interface
that defines its methods for use by other services and other layers. Traditionally, defining an interface and only having one class implement it is an anti-pattern. The purpose of defining all services through an interface is to support mocked dependencies during testing. In this sense, the interfaces actually do have multiple implementations, within the solution as a whole, there's just only one implementation within the deployed application.
E.G.
public class UserService
: IUserService
{
...
public async Task<IGuildUser> GetGuildUserAsync(ulong guildId, ulong userId)
{
var guild = await DiscordClient.GetGuildAsync(guildId);
if (guild == null)
throw new InvalidOperationException($"Discord guild {guildId} does not exist");
var user = await guild.GetUserAsync(userId);
if (user == null)
throw new InvalidOperationException($"Discord user {userId} does not exist");
await TrackUserAsync(user);
return user;
}
...
}
Authorization is the act of ensuring that a user attempting to perform an operation is actually allowed to perform that operation. This is a core functionality of all user-facing operations defined in the Business Logic layer.
Authorization within MODiX is primarily permissions-based. Permissions are defined by the AuthorizationClaim
enum, which is really a bad name, but for the moment, that's what it is. Permissions are assumed to be "Denied" by default, and then can be mapped to users both directly (via UserId) and through Discord roles (via RoleId). Permissions mappings can also be specifically "Granted" or "Denied", allowing for user-based mappings to override role-based mappings.
E.G.
public class AuthorizationService
: IAuthorizationService
{
...
public void RequireClaims(params AuthorizationClaim[] claims)
{
RequireAuthenticatedUser();
if (claims == null)
throw new ArgumentNullException(nameof(claims));
var missingClaims = claims
.Except(CurrentClaims)
.ToArray();
if (missingClaims.Length != 0)
// TODO: Booooo for exception-based flow control
throw new InvalidOperationException($"The current operation could not be authorized. The following claims were missing: {string.Join(", ", missingClaims)}");
}
...
}
public class ModerationService
: IModerationService
{
...
public async Task DeleteInfractionAsync(long infractionId)
{
AuthorizationService.RequireAuthenticatedUser();
AuthorizationService.RequireClaims(AuthorizationClaim.ModerationDeleteInfraction);
...
}
...
}
One of the primary jobs of MODiX is to interact with and enhance the Discord experience. As such, making use of Discord Roles and Channels to perform MODiX Business tasks is a pretty common thing, and thus we have the shared ChannelDesignation and RoleDesignation systems that any feature in the Business Logic layer can utilize.
Designations are defined by the DesignatedChannelType
and DesignatedRoleType
enums and mapped to actual channels and roles within the database, via DesignatedChannelMappingEntity
and DesignatedRoleMappingEntity
. Business entities should interact with mappings via DesignatedChannelService
and DesignatedRoleService
, which provide logic for performing common operations upon roles and channels, such as sending messages to all channels with a particular designations, and also implement caching optimizations.
E.G.
public class ModerationLoggingBehavior
: IModerationActionEventHandler
{
...
public async Task OnModerationActionCreatedAsync(long moderationActionId, ModerationActionCreationData data)
{
if (!await DesignatedChannelService.AnyDesignatedChannelAsync(data.GuildId, DesignatedChannelType.ModerationLog))
return;
...
var message = ...
await DesignatedChannelService.SendToDesignatedChannelsAsync(
await DiscordClient.GetGuildAsync(data.GuildId), DesignatedChannelType.ModerationLog, message);
}
...
}
One of the primary mechanisms of communication between different layers of the application, or between different services within the Business layer, is the Messaging system, which follows the Mediator pattern. The core concept of messaging is the use of a "messenger" class to distribute "notifications" throughout the application. This essentially parallels the idea of C# events, except that it doesn't rely on classes that want to send a notification maintaining references to all the classes that want to receive the notification, as is the case with events. This has a variety of advantages, such as eliminating the potential memory leak of event subscriptions, allowing notification receivers to be configured through the IoC container, like pretty much everything else in the application, and allowing for notifications to be generated by multiple sources, rather than just one.
The messaging system also helps support parallelism within the application, as INotificationHandler<T>
implementations serve as convenient "entry points" into parallel logical threads, as described above. In fact, the messaging system is one of the two primary means by which parallel logical threads are spawned, the other being the ASP.NET Core HTTP Application Pipeline.
E.G.
public class PromotionsService
: IPromotionsService
{
...
public async Task RejectCampaignAsync(long campaignId)
{
AuthorizationService.RequireAuthenticatedUser();
AuthorizationService.RequireClaims(AuthorizationClaim.PromotionsCloseCampaign);
if (!(await PromotionCampaignRepository.TryCloseAsync(campaignId, AuthorizationService.CurrentUserId.Value, PromotionCampaignOutcome.Rejected) is PromotionActionSummary resultAction))
throw new InvalidOperationException($"Campaign {campaignId} doesn't exist or is already closed");
PublishActionNotificationAsync(resultAction);
}
...
private void PublishActionNotificationAsync(PromotionActionSummary action)
=> MessageDispatcher.Dispatch(new PromotionActionCreatedNotification(
action.Id,
new PromotionActionCreationData
{
Created = action.Created,
CreatedById = action.CreatedBy.Id,
GuildId = action.GuildId,
Type = action.Type,
}));
...
}
public class PromotionLoggingHandler :
INotificationHandler<PromotionActionCreatedNotification>
{
...
public async Task HandleNotificationAsync(PromotionActionCreatedNotification notification, CancellationToken cancellationToken)
{
...
if (await DesignatedChannelService.AnyDesignatedChannelAsync(notification.Data.GuildId, DesignatedChannelType.PromotionLog))
{
var message = await FormatPromotionLogEntryAsync(notification.Id);
if (message == null)
return;
await DesignatedChannelService.SendToDesignatedChannelsAsync(
await DiscordClient.GetGuildAsync(notification.Data.GuildId), DesignatedChannelType.PromotionLog, message);
}
if (await DesignatedChannelService.AnyDesignatedChannelAsync(notification.Data.GuildId, DesignatedChannelType.PromotionNotifications))
{
var embed = await FormatPromotionNotificationAsync(notification.Id, notification.Data);
if (embed == null)
return;
await DesignatedChannelService.SendToDesignatedChannelsAsync(
await DiscordClient.GetGuildAsync(notification.Data.GuildId), DesignatedChannelType.PromotionNotifications, "", embed);
}
}
...
}
The system of IBehavior
and BehaviorBase
are deprecated systems for defining internal business logic. An IBehavior
represents a "behavior" of the application that requires code to execute during application startup and/or application teardown. The MODiX Host Process is written to ensure that all IBehavior
implementations registered with the Dependency Injection system have their startup code invoked during application startup, before the HTTP Pipeline and Discord Client begin listening for incoming data, and similarly have their teardown code invoked after they have stopped listening. Most commonly, IBehavior
s are used to subscribe to events on a DiscordSocketClient
, thus defining behaviors in the form of code that executes for certain messages received through the Discord Gateway connection.
As of version 3.0, ASP.NET Core has been refactored and streamlined to operate under the umbrella of the Microsoft Generic Hosting provider (Microsoft.Extensions.Hosting), and in particular, the IHostedService
interface, which has been around for a while and is basically identical to IBehavior
, is now supported as a startup mechanism by WebHost
. That is, all IHostingProvider.StartAsync()
methods are guaranteed to complete before the HTTP Application begins listening for incoming connections. This makes IBehavior
quite literally obsolete.
Additionally, the majority of "behaviors" defined through IBehavior
implementations can be implemented instead through the messaging system, by implementing INotificationHandler<T>
for notifications corresponding to DiscordSocketClient
events, which are distributed by Modix.Services.Core.DiscordSocketListeningBehavior
. In fact, many behaviors previously implemented through IBehavior
have already been refactored in this method.
Testing within the Business Logic layer should generally involve desting business decisions and data transforms that the layer performs. This is achieved primarily through the use of Moq, which allows for mocked dependencies to be built on a per-test basis, in a fluent-syntax manner. The majority of assertions for tests within this layer are gonna be assertions against Mock<T>
objects to see if certain methods were invoked, and what the arguments were.
E.G.
[TestFixture]
public class InvitePurgingBehaviorTests
{
private static (AutoMocker autoMocker, InvitePurgingBehavior uut) BuildTestContext()
{
var autoMocker = new AutoMocker();
var uut = autoMocker.CreateInstance<InvitePurgingBehavior>();
var mockSelfUser = autoMocker.GetMock<ISocketSelfUser>();
mockSelfUser
.Setup(x => x.Id)
.Returns(1);
autoMocker.GetMock<ISelfUserProvider>()
.Setup(x => x.GetSelfUserAsync(It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(mockSelfUser.Object));
autoMocker.GetMock<IDiscordClient>()
.Setup(x => x.GetInviteAsync(It.IsIn(GuildInviteCodes), It.IsAny<RequestOptions>()))
.ReturnsAsync((string code, RequestOptions _) =>
{
...
});
autoMocker.GetMock<IDiscordClient>()
.Setup(x => x.GetInviteAsync(It.IsNotIn(GuildInviteCodes), It.IsAny<RequestOptions>()))
.ReturnsAsync((string code, RequestOptions _) =>
{
...
});
return (autoMocker, uut);
}
private static ISocketMessage BuildTestMessage(AutoMocker autoMocker, string content)
{
var mockMessage = autoMocker.GetMock<ISocketMessage>();
var mockGuild = autoMocker.GetMock<IGuild>();
var mockAuthor = autoMocker.GetMock<IGuildUser>();
...
var mockChannel = autoMocker.GetMock<IMessageChannel>();
...
return mockMessage.Object;
}
...
[Test]
public async Task HandleNotificationAsync_MessageReceivedNotification_MessageChannelGuildIsNull_DoesNotDeleteMessage()
{
(var autoMocker, var uut) = BuildTestContext();
var notification = new MessageReceivedNotification(
message: BuildTestMessage(autoMocker, DefaultInviteLink));
autoMocker.GetMock<IMessageChannel>()
.As<IGuildChannel>()
.Setup(x => x.Guild)
.Returns<IGuild>(null);
await uut.HandleNotificationAsync(notification);
autoMocker.MessageShouldNotHaveBeenDeleted();
}
...
}
internal static class InvitePurgingBehaviorAssertions
{
public static void MessageShouldNotHaveBeenDeleted(this AutoMocker autoMocker)
{
autoMocker.GetMock<IModerationService>()
.ShouldNotHaveReceived(x => x.
DeleteMessageAsync(
It.IsAny<IMessage>(),
It.IsAny<string>(),
It.IsAny<ulong>()));
autoMocker.GetMock<IMessageChannel>()
.ShouldNotHaveReceived(x => x.
SendMessageAsync(
It.IsAny<string>(),
It.IsAny<bool>(),
It.IsAny<Embed>(),
It.IsAny<RequestOptions>()));
}
}
See Testing for more information.
If you encounter anything confusing about the MODiX codebase, have a look at Application Architecture, or just hop into #modix-development and ask us directly.