Skip to content

Commit

Permalink
Created FeatureFlagging services, APIs and source generator, and Flag…
Browse files Browse the repository at this point in the history
…smith adapter, with stubs, and docs. #4
  • Loading branch information
jezzsantos committed Feb 19, 2024
1 parent 004141b commit 3543049
Show file tree
Hide file tree
Showing 129 changed files with 3,739 additions and 119 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@

# SaaStack

Are you about to build a new SaaS product from scratch and do that on .NET?
Are you about to build a new SaaS product from scratch? On .NET?

Then, start with SaaStack.
Then, try starting with SaaStack codebase template.

It is a complete "codebase template" for building real-world, fully featured SaaS web products.
It is a complete template for building real-world, fully featured SaaS web products.

Ready to build, test, and deploy into a cloud provider of your choice (e.g., Azure, AWS, Google Cloud, etc.)

> Don't spend months building all this stuff from scratch. You and your team don't need to. We've done all that for you already; just take a look, see hat is there and take it from here. You can always change it the way you like it as you proceed, you are not locked into anyone else framework.
> Don't spend months building all this stuff from scratch. You and your team don't need to. We've done all that for you already; just take a look, see what is already there and take it from here. You can always change it the way you like it as you proceed, you are not locked into anyone else's framework.
>
> This is not some code sample like those you would download to learn a new technology or see in demos online. This is way more comprehensive, way more contextualized, and way more realistic about the complexities you are going to encounter in reality.
> This template contains a partial (but fully functional) SaaS product that you can deploy from day one and start building your product on. But it is not yet complete. That part is up to you.
> This template contains a partial (but fully functional) SaaS product that you can deploy from day one and start building your product on. But it is not yet complete. That next part is up to you.
The codebase demonstrates common architectural styles that you are going to need in your product in the long run, such as:

Expand All @@ -42,7 +42,7 @@ or if you prefer AWS:

## Who is it for?

This starter template is NOT for everyone, nor for EVERY software project, nor for EVERY skill level.
This starter template is NOT for everyone, nor for EVERY software project, nor for EVERY skill level. We need to say that because all software products are different, there is not one silver bullet for all of them.

* The people using this template must have some experience applying "first principles" of building new software products from scratch because it is a starter template that can (and should) be modified to suit your context. It is a far better starting point than building everything from scratch again.

Expand Down Expand Up @@ -126,7 +126,7 @@ The starter template also takes care of these specific kinds of things:
* It integrates product usage metrics to monitor and measure the actual usage of your product (e.g., MixPanel, Google Analytics, Application Insights, Amazon XRay, etc.)
* It integrates crash analytics and structured logging so you can plug in your own preferred monitoring (e.g., Application Insights, CloudWatch, Sentry.io, etc.).
* It uses dependency injection extensively so that all modules and components remain testable and configurable.
* It defines standard and unified configuration patterns (e.g., using appsettings.json) to load tenanted or non-tenanted runtime settings.
* It defines standard and unified configuration patterns (e.g., using `appsettings.json`) to load tenanted or non-tenanted runtime settings.
* Application
* Supports one or more applications, agnostic to infrastructure interop (i.e., allows you to expose each application as a REST API (default) or as a reliable Queue, or any other kind of infrastructure)
* Supports transaction scripts + anemic domain model or Domain Driven Design
Expand Down
8 changes: 8 additions & 0 deletions README_DERIVATIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ Now, test that LocalStack works by running: `localstack start`

> When testing, Docker will need to be running for LocalStack to be used

### External Adapter Testing

> You only need to perform this step once, prior to running any of the `Integration.External` tests against 3rd party adapters (e.g., Flagsmith, Twillio, etc)

In the `Infrastructure.Shared.IntegrationTests` project, create a new file called `appsettings.Testing.local.json` and fill out the empty placeholders you see in `appsettings.TestingOnly.json` with values from service accounts that you have created for testing those 3rd party services.

> DO NOT add this file to source control!

# Build & Deploy

When pushed, all branches will be built and tested with GitHub actions
Expand Down
288 changes: 287 additions & 1 deletion docs/design-principles/0080-ports-and-adapters.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions docs/design-principles/0120-feature-flagging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Feature Flagging

## Design Principles

* We want to be able to deploy code which includes features/code that we dont want visible/available/enabled for end users.
* We want to be able to progressively roll-out certain features to specific users, or segments of the market to manage any risk of deploying new features
* This optionality can be attributed to all end-users, or specific end-users, or even all users within a specific tenant
* We want those features to be configured externally to the running system, without changing what has been deployed
* We want to have those flags managed separately to our system, so that we don't have to build this kind of infrastructure ourselves

## Implementation

We have provided a service called `IFeatureFlags` that is available in any component of the architecture.

> That service will be implemented by an adapter to a 3rd party external system such as FlagSmith, GitLab, LaunchDarkly etc.
We have also provided an API to access this capability from the BEFFE, so flags can be shared in the Frontend JS app.

The interface `IFeatureFlags` provides methods to query flags in the system, using pre-defined flags in the code, that should be represented in the 3rd party system.

For example,

```c#
public class MyClass
{
private readonly IFeatureFlags _featureFlags;

public MyClass(IFeatureFlags featureFlags)
{
_featureFlags = featureFlags;
}

public void DoSomethingForAllUsers()
{
if (_featureFlags.IsEnabled(Flag.MyFeature))
{
...do somethign with this feature
}
}

public void DoSomethingForTheCallerUser(ICallerContext caller)
{
if (_featureFlags.IsEnabled(Flag.MyFeature, caller))
{
...do somethign with this feature
}
}
}
```

Where `MyFeature` is defined as a flag in `FeatureFlags.resx` file in the `Common` project.

### Defining flags

In code, flags are defined in the `FeatureFlags.resx` file in the `Common` project.

A source generator runs every build to translate those entries in the resource file to instances of the `Flags` class, to provide a typed collection of flags for use in code.

> This provides an easy way for intellisense to offer you the possible flags in the codebase to avoid using flags that no longer exist.
5 changes: 4 additions & 1 deletion docs/design-principles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@
* [Dependency Injection](0060-dependency-injection.md) how you implement DI
* [Persistence](0070-persistence.md) how you design your repository layer, and promote domain events
* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arms length, and testable, and how we integrate with any 3rd party system
* [Backend for Frontend](0900-back-end-for-front-end.md) the web server that is tailored for a web UI, and brokers the backend
* [Authentication and Authorization](0090-authentication-authorization.md) how we authenticate and authorize users
* [Email Delivery](0100-email-delivery.md) how we send emails and deliver them asynchronously and reliably
* [Backend for Frontend](0110-back-end-for-front-end.md) the BEFFE web server that is tailored for a web UI, and brokers secure access to the backend
* [Feature Flagging](0120-feature-flagging.md) how we enable and disable features at runtime
Binary file added docs/images/Ports-And-Adapters.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Sources.pptx
Binary file not shown.
7 changes: 6 additions & 1 deletion src/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,9 @@ Desktop.ini
$RECYCLE.BIN/

# Mac desktop service store files
.DS_Store
.DS_Store

# Local configuration
local.settings.json
appsettings.local.json
appsettings.Testing.local.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/AWSLambdas.Api.WorkerHost/HostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Application.Persistence.Shared.ReadModels;
using Common;
using Common.Configuration;
using Common.FeatureFlags;
using Common.Recording;
using Infrastructure.Common.Recording;
using Infrastructure.Hosting.Common;
Expand All @@ -24,6 +25,7 @@ public static void AddDependencies(this IServiceCollection services, IConfigurat
services.AddHttpClient();
services.AddSingleton<IConfigurationSettings>(new AspNetConfigurationSettings(configuration));
services.AddSingleton<IHostSettings, HostSettings>();
services.AddSingleton<IFeatureFlags, EmptyFeatureFlags>();

#if TESTINGONLY
services.AddSingleton<ICrashReporter>(new NullCrashReporter());
Expand Down
85 changes: 85 additions & 0 deletions src/AncillaryApplication.UnitTests/FeatureFlagsApplicationSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Application.Interfaces;
using Common;
using Common.FeatureFlags;
using FluentAssertions;
using Moq;
using UnitTesting.Common;
using Xunit;

namespace AncillaryApplication.UnitTests;

[Trait("Category", "Unit")]
public class FeatureFlagsApplicationSpec
{
private readonly FeatureFlagsApplication _application;
private readonly Mock<ICallerContext> _caller;
private readonly Mock<IFeatureFlags> _featuresService;

public FeatureFlagsApplicationSpec()
{
var recorder = new Mock<IRecorder>();
_caller = new Mock<ICallerContext>();
_caller.Setup(cc => cc.IsAuthenticated).Returns(true);
_caller.Setup(cc => cc.CallerId).Returns("acallerid");
_caller.Setup(cc => cc.TenantId).Returns("atenantid");
_featuresService = new Mock<IFeatureFlags>();
_featuresService.Setup(fs => fs.GetFlagAsync(It.IsAny<Flag>(), It.IsAny<Optional<string>>(),
It.IsAny<Optional<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FeatureFlag
{
Name = "aname",
IsEnabled = true
});
_application = new FeatureFlagsApplication(recorder.Object, _featuresService.Object);
}

[Fact]
public async Task WhenGetFeatureFlag_ThenReturns()
{
var result =
await _application.GetFeatureFlagAsync(_caller.Object, "aname", null, "auserid", CancellationToken.None);

result.Should().BeSuccess();
result.Value.Name.Should().Be("aname");
result.Value.IsEnabled.Should().BeTrue();
_featuresService.Verify(fs => fs.GetFlagAsync(It.Is<Flag>(flag => flag.Name == "aname"), Optional<string>.None,
"auserid", It.IsAny<CancellationToken>()));
}

[Fact]
public async Task WhenGetFeatureFlagForCaller_ThenReturns()
{
var result =
await _application.GetFeatureFlagForCallerAsync(_caller.Object, "aname", CancellationToken.None);

result.Should().BeSuccess();
result.Value.Name.Should().Be("aname");
result.Value.IsEnabled.Should().BeTrue();
_featuresService.Verify(fs => fs.GetFlagAsync(It.Is<Flag>(flag =>
flag.Name == "aname"
), "atenantid", "acallerid", It.IsAny<CancellationToken>()));
}

[Fact]
public async Task WhenGetAllFeatureFlags_ThenReturns()
{
_featuresService.Setup(fs => fs.GetAllFlagsAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<FeatureFlag>
{
new()
{
Name = "aname",
IsEnabled = true
}
});

var result =
await _application.GetAllFeatureFlagsAsync(_caller.Object, CancellationToken.None);

result.Should().BeSuccess();
result.Value.Count.Should().Be(1);
result.Value[0].Name.Should().Be("aname");
result.Value[0].IsEnabled.Should().BeTrue();
_featuresService.Verify(fs => fs.GetAllFlagsAsync(It.IsAny<CancellationToken>()));
}
}
20 changes: 18 additions & 2 deletions src/AncillaryApplication/FeatureFlagsApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,32 @@ public async Task<Result<List<FeatureFlag>, Error>> GetAllFeatureFlagsAsync(ICal
return flags.Value.ToList();
}

public async Task<Result<FeatureFlag, Error>> GetFeatureFlagForCallerAsync(ICallerContext context, string name,
CancellationToken cancellationToken)
{
var flag = await _featureFlags.GetFlagAsync(new Flag(name), context, cancellationToken);
if (!flag.IsSuccessful)
{
return flag.Error;
}

_recorder.TraceInformation(context.ToCall(),
"Feature flag {Name} was retrieved for user {User} in tenant {Tenant}", name, context.CallerId,
context.TenantId ?? "none");

return flag.Value;
}

public async Task<Result<FeatureFlag, Error>> GetFeatureFlagAsync(ICallerContext context, string name,
string? tenantId, string? userId, CancellationToken cancellationToken)
string? tenantId, string userId, CancellationToken cancellationToken)
{
var flag = await _featureFlags.GetFlagAsync(new Flag(name), tenantId, userId, cancellationToken);
if (!flag.IsSuccessful)
{
return flag.Error;
}

_recorder.TraceInformation(context.ToCall(), "Feature flag {Name} was retrieved", name);
_recorder.TraceInformation(context.ToCall(), "Feature flag {Name} was retrieved for user {User}", name, userId);

return flag.Value;
}
Expand Down
17 changes: 17 additions & 0 deletions src/AncillaryApplication/IFeatureFlagsApplication.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Application.Interfaces;
using Common;
using Common.FeatureFlags;

namespace AncillaryApplication;

public interface IFeatureFlagsApplication
{
Task<Result<List<FeatureFlag>, Error>> GetAllFeatureFlagsAsync(ICallerContext context,
CancellationToken cancellationToken);

Task<Result<FeatureFlag, Error>> GetFeatureFlagAsync(ICallerContext context, string name, string? tenantId,
string userId, CancellationToken cancellationToken);

Task<Result<FeatureFlag, Error>> GetFeatureFlagForCallerAsync(ICallerContext context, string name,
CancellationToken cancellationToken);
}
2 changes: 1 addition & 1 deletion src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public void WhenSucceededDelivery_ThenDelivered()
root.Delivered.Should().BeNear(DateTime.UtcNow);
root.Events.Last().Should().BeOfType<Events.EmailDelivery.DeliverySucceeded>();
}

private static QueuedMessageId CreateMessageId()
{
var messageId = new MessageQueueIdFactory().Create("aqueuename");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Net;
using ApiHost1;
using Common.FeatureFlags;
using FluentAssertions;
using Infrastructure.Web.Api.Common.Extensions;
using Infrastructure.Web.Api.Operations.Shared.Ancillary;
using IntegrationTesting.WebApi.Common;
using IntegrationTesting.WebApi.Common.Stubs;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Task = System.Threading.Tasks.Task;

namespace AncillaryInfrastructure.IntegrationTests;

[Trait("Category", "Integration.Web")]
[Collection("API")]
public class FeatureFlagsApiSpec : WebApiSpec<Program>
{
private readonly StubFeatureFlags _featureFlags;

public FeatureFlagsApiSpec(WebApiSetup<Program> setup) : base(setup, OverrideDependencies)
{
EmptyAllRepositories();
_featureFlags = setup.GetRequiredService<IFeatureFlags>().As<StubFeatureFlags>();
_featureFlags.Reset();
}

[Fact]
public async Task WhenGetAllFeatureFlags_ThenReturnsFlags()

Check warning on line 29 in src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 29 in src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
#if TESTINGONLY
var request = new GetAllFeatureFlagsRequest();

var result = await Api.GetAsync(request, req => req.SetHMACAuth(request, "asecret"));

result.StatusCode.Should().Be(HttpStatusCode.OK);
result.Content.Value.Flags.Count.Should().Be(0);
#endif
}

[Fact]
public async Task WhenGetFeatureFlag_ThenReturnsFlag()

Check warning on line 42 in src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.

Check warning on line 42 in src/AncillaryInfrastructure.IntegrationTests/FeatureFlagsApiSpec.cs

View workflow job for this annotation

GitHub Actions / build

This async method lacks 'await' operators and will run synchronously. Consider using the 'await' operator to await non-blocking API calls, or 'await Task.Run(...)' to do CPU-bound work on a background thread.
{
#if TESTINGONLY
var request = new GetFeatureFlagForCallerRequest
{
Name = Flag.TestingOnly.Name
};

var result = await Api.GetAsync(request, req => req.SetHMACAuth(request, "asecret"));

result.StatusCode.Should().Be(HttpStatusCode.OK);
result.Content.Value.Flag!.Name.Should().Be(Flag.TestingOnly.Name);
_featureFlags.LastGetFlag.Should().Be(Flag.TestingOnly.Name);
#endif
}

private static void OverrideDependencies(IServiceCollection services)
{
// nothing here yet
}
}
Loading

0 comments on commit 3543049

Please sign in to comment.