SOLID and Dynamics 365 plugin - handshake

Could it be love

Creating a framework, using the term SOLID as part of the framework name is calling for an explanation. The framework it self is only a bridge between a software development princip, and a very specific type of software (Dynamics 365 plugin library), actually implementing one interface only.

On top, a plugin library is not intended to be extended by any other code (if we for the sake of simplicity ignores the unit test library). So why does it make sense to ame for software princips that is targeting simplicity, reusablility, extendability.

If your need for plugins is very simple, very few, you might consider this for overkill. If that is the case, please remember that using this library is as simple as installing a NuGet package. You are up running in a few minutes, and you can start small. Starting with a good approach will allow you to grow, remodel and extend from the very low ambitions you might be starting with.

Also remember, that building plugins is basically the art of setting up automatic rules and guards on your Dyanmics 365 database. Even though the system is out-of-box mostly a simple CRM system, the platform is a full fledge business application platform, allowing you to impl. any kind of business need on top of the concept. If you take your Dynamics 365 investment to a higher level, you might wish to be able to customize your Business application infrastructure in a much more ambitios way. Building on top of a framework that focus on helthy software princips will help on the journey.

So lets get into the details of understanding SOLID in a Dynamics 365 plugin context

Solid is an acronym, and you can see the short description for each letter in the left menu. You will find a walk through of each letter below.

The explanation below is not in any sense exhaustive in terms of explaining neether the Kipon.Solid framework, nore the SOLID software princips. It only aims to give you a basic understand of the concepts, and give you examples on how the framework helps you follow these princips.

Single responsibility principle

Single responsibility, and often added with one reason to change only should be your overall guidance when creating classes in your library. Whenever you create code, you should ask your self, "is the class the right place to put this responsibility". To be able to answer this question, it is a good idea to actually express the responsibility of the class as you create it, and that also give you the chance to give the class a good name. Personally i try to add a comment in the top of each class, where I explicity states the responsibility and reason. This is very helpfull when i later revisit the class for the purpose of adding functionality and more. Then i can validate the new requirement against this description and thereby get guidance if it is the right place to put the code.


namespace Kipon.PluginExample.Plugins.Account.v0
{
    // purpose: Map entity events related to the account entity to appropriate services
    // reason: changes in how account events should map to the services.
    public class AccountPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Account target, Microsoft.Xrm.Sdk.IOrganizationService orgService)
        {
        }
    }
}

Above is a simple example of the primary class type of an assembly library, the plugin implementation. One responsibility, and one reason to change is not a 100% exact term that defines exactly when to create an additional class. The naming "AccountPlugin" indicates that each entity will have one and only one plugin, and then one or more methods for each event to listen for. Depending on the number of maps you will have for the entity, this could be an appropriate scope for the purpose, but one could argue that a single plugin could be sufficient, the purpose would be map events for relevant entities to service, you might end up having a lot of On...() methods in that class, and it could end up become messy, or you could go the other way and create a plugin for each Entity+Stage, ex AccountPreCreatePlugin. There is no way to give an exact answer on this. My experience is that having a plugin for each entity is giving a resonable size of each plugin, and you do not loose the overview. Making a lot of very small classes, you might end up loosing the overview, while putting too much on one plate is giving the same pain.

From my experience one single plugin per entity it a good cut, but it is important that code in plugin ONLY map events to underlying services to keep the size of the code small, and the responsibility clean.

The importance here is not really if you decide for one plugin per entity, or one per entity per event etc. The important statement is that the purpose is to delegate whatever has to be done to some service. So ... if you start do actually "work" in the plugin, you are off-road. That is the main point. The purpose of the plugin class, is as stated, to delegate events to appropriate services. So the plugin it self should do no work beside delegate events to services.

The plugin should not de any work it self, but only delegate events to services.

But the above example only provide the standard SDK organization service, and that cat cannot do much without writing code that goes beyond simple map of events to services.

Lets introduce a service. Before we do so, we must have a "problem" to solve.

For the sake of simplicity, the requirement we will work with is a simple functionality to wash telephone numbers before they go into the CRM. People are not consistent in the way they write telephone number. Some would write 34567890, others would write 34 56 78 90, or +45 34-56-78-90. (we are working with DK numbers).

What we wish to do is setup a simple workflow that align all danish phonenumber to be on the format +4534567890


namespace Kipon.PluginExample.ServiceAPI.v1
{
    // purpose: Provide interface to align phone numbers
    public interface IPhoneWashService
    {
        string Align(string phonenumber);

    }
}

The above code defines a simple service, with a single method, giving a phonenumber it should return the aligned phone number


using Microsoft.Xrm.Sdk;

namespace Kipon.PluginExample.Services.v1
{
    public class PhoneWashService : Kipon.PluginExample.ServiceAPI.v1.IPhoneWashService
    {
        private readonly ITracingService traceService;

        public PhoneWashService(Microsoft.Xrm.Sdk.ITracingService traceService)
        {
            this.traceService = traceService;
        }

        public string Align(string phonenumber)
        {
            this.traceService.Trace($"just to show injection of standard service and usage");

            if (string.IsNullOrEmpty(phonenumber)) return phonenumber;
            phonenumber = phonenumber.Replace(" ", "").Replace(".", "").Replace("-", "").Trim();
            if (phonenumber.Length == 8 && !phonenumber.StartsWith("+")) return $"+45{phonenumber}";
            return phonenumber;
        }
    }
}

Take a look at the above impl. of the IPhoneWashService. Do not look too much into the actual impl. of align. That is trivial, and does not take all cases into considuration. The important part here is to demonstrate how to create a service, that get something else (here the Tracking service) injected through the constructor

With this piece of code in place, we can now request the service in our plugin event method:


namespace Kipon.PluginExample.Plugins.Account.v1
{
    public class AccountPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Account target, ServiceAPI.v1.IPhoneWashService washService)
        {
            if (target.Telephone1 != null) target.Telephone1 = washService.Align(target.Telephone1);
        }
    }
}

... and eventually use the service, to react on a filled out phone number in the target account, and adjust the value to be conform with the wash impl. rules.

I know you will say, but but but, there are several other phone numbers on the account, and what about update. Please be patient. We take one step at a time, and we need additional SOLID princips to be in place before we can take such huge steps in our design. Just continue reading, and you will see how it all adds up.

Now we understand how Single Responsibility maps into a plugin, and we have seen a very simple service, based on a simple API that can be injected into the plugin event method. Each service defined should also have one responsibility only, and only one reason to change. As a basic princip it is better to have a lot of small interfaces that are unlikely to change ever, than large interfases.

As a basic navigation pattern in deviding things into services, look at the methods and the related signature (parameters). The more methods you have in a single service, the more responsibility you are giving the service, and the more "generic" you will have to state the "single-responsibility". If you state the responsibillity as "Do-work", you can add anything to the service, but it will not make it very solid. ... and many parameters to a single method often means a lot of responsibility and complexity in such method. They are harder to refactor and might become blobs over time, and you make it hard for the test team to write good unit test, because what is to be expected for such a complex interface.

Also remember, that all dependencies should be based on interfaces, not implementations, and you can have a lot of interfaces, implemented by a single service if that suiets you. As long as you never make any direct references to your implementations.

Open - closed

The Open Closed princip is about "designing" for the future while respecting the past. Lets start with the "closed" part. As soon as you have published an interface, and it is running in your production system, you are obligated to maintain the logic as-is, or explicitly state that your service has changed, and anybody using your service should align accordingly. You might think that it is sufficient to look in your code to see the lines of code that is referencing your interface, but this obligation goes way beyond that point. You might have dependencies in your total landscape that you are not even aware of.

Lets keep the discussion in the phonenumber problem, you get a suggestion from a user, could we add "-" between the country code and the phone number to make it more readable, "+45-34567890", and you look into your code, and says, mjuaa, we can do that, we will need to make a small change in the code, and a datamigration on all existing phone number - but that is an easy task.

But you might bee wrong. Other service like an SMS service or similar might extract information from the account record, using SDK or Web API, and assume the phone number has been correct format using the above algoritm, so numbers are parsed directly into a third party service, assuming the format is correct. This external service will start fail if you make a change. You could of cause state, that such service should do its own laundary and in this paricular situation i will proberbly agree on that part, but that does not take away your obligation to your promise, and you still have to make sure every possible stakeholder is aware if you change the service. So the closed part is all about keeping your promise, and only make changes that will not impact the behavior the system it self, or any external system, - even thouse where you are unaware of there exsistance.

The open part means that you should design in a way where others can easily extend you service. Such extension can be of different types. What if i need to support multi country codes?, what if the default should not be +45 (DK), and should the interface allow some kind of fallback mechanism, so the "client" using the interface can add its own formating algoritm in the case where this implementation is unable to add correct formating.

It will be to much to try to create examples for all these design adjustments. The country code, would proberbly be an implementation detail and not change the inteface at all. The actual impl. could as for a configuration service that could provide the default country code. Even the fallback can be added in a gracefull way.


using System;
namespace Kipon.PluginExample.ServiceAPI.v2
{
    // purpose: Provide interface to align phone numbers
    public interface IPhoneWashService
    {
        string Align(string phonenumber, Func<string, string> fallback = null);
    }
}

The above change to the align inteface is "gracefull", because it adds the possibility for the client to add its own fallback function, but allow null as default, so any code using the interface will still work without a fallback

But let me also make a clear statement, the changed interface is less than perfect, because it encourage a pattern, where the phone number formatting algoritm is distributed so each "client" have to take formatting decissions, and such problem should be 100% centralized. But for the sake of the example, lets keep it anyway.


using System;

namespace Kipon.PluginExample.Services.V2
{
    public class PhoneWashService : ServiceAPI.v2.IPhoneWashService
    {
        public string Align(string phonenumber, Func<string, string> fallback = null)
        {
            if (string.IsNullOrEmpty(phonenumber)) return phonenumber;
            phonenumber = phonenumber.Replace(" ", "").Replace(".", "").Replace("-", "").Trim();
            if (phonenumber.Length == 8 && !phonenumber.StartsWith("+")) return $"+45{phonenumber}";

            if (phonenumber.Length == 11 && phonenumber.StartsWith("+45")) return phonenumber;
            if (fallback != null) return fallback.Invoke(phonenumber);

            return phonenumber;
        }
    }
}

The new implementation of the phone wash service is equally implementing the new functionaly gracefully. It only change the behavior of the service in situations where no maps was done at all, and still returns the initial phonenumber if no fallbackMap is provided.

There is a huge amount of possibilities how we can improve this service. But the main point here is not to create a phone number wash service. The purpose is to give an example on the open - closed princip, and the thoughts you should put behind each change you plan to do to an existing service.

Liskov substitution

Liskov substitution is mostly about being explcit in your interfaces, and avoid hidden agenda. Any implementation of an interface can be substitute with another implementation of the same interface. If you violate this princip ex. by taking assumptions in your code that is not strictly defined in the interface, it is a violation against this princip.

To understand this in more details, lets take our phoneservice to the next level. For now we have just impl. a simple service that can convert strings and apply a certain pattern. To make this work in in our CRM instance, we would need to listen for a number of records (account, contact, lead, phonecall), and each of these entities even have more than one phonenumber. On top of that, we will need to take into considuration that the numbers can be set on create and update of a record.

Lets create an interface to define this assignment. First we need some input. We need an interface that can take any "entity" in CRM, and then align all phonenumber fields if applicable:


namespace Kipon.PluginExample.Models.v3
{
    public interface IPhonenumberChanged
    {
        string[] Fields { get; }
        Microsoft.Xrm.Sdk.AttributeCollection Attributes { get; }
    }
}

Above interface defines the very basic interface to allow a service to find all telephone numbers in an entity (Fields property), get the value, and set the value, via the 100% generic Attributes property that is available in any Microsoft.Xrm.Sdk.Entity. The Attributes property of any entity is populated with the actual payload of an event - any time in the pipeline. So if a clien UI i creating and entity, where Name and Telephone1 has been set, the Attributes collection of the entity will contain these two values. So lets extend our phone wash service to allow it to work on this interface. (I have taken out the fallback in this example. The fallback is not the right way to impl. different pattern-match for different countries, so no need to continue on that story).


using System;
namespace Kipon.PluginExample.ServiceAPI.v3
{
    // purpose: Provide interface to align phone numbers
    public interface IPhoneWashService
    {
        string Align(string phonenumber);
        void Wash(Models.v3.IPhonenumberChanged target);
    }
}

We now have a method Wash, that take an implementation of IPhonenumberChanged. This method should be able to align any number of telephone number is any class that implements this interface. Below the method that will be added to the service to implement the new method.


public void Wash(IPhonenumberChanged target)
{
    foreach (var field in target.Fields)
    {
        var f = field.ToLower();
        if (target.Attributes.ContainsKey(f) && target.Attributes[f] is string s)
        {
            target.Attributes[f] = this.Align(s);
        }
    }
}

Now we have a service that can wash telefon numbers in a class, as long as the class can tell witch fields are telephone numbers. But any CU events from dynamics 365 is comming with an Microsoft.Xrm.Sdk.Entity as target. The Kipon.Solid framework takes advantages of the crmsvcutil to generate strongly typed entites for each entity, so we also have a specific class for each entity, so lets make sure that the entity for accounts, implements this interface:


namespace Kipon.PluginExample.Entities
{
    public partial class Account : Models.v3.IPhonenumberChanged
    {
        private static readonly string[] FIELDS = new string[]
        {
            nameof(Account.Telephone1).ToLower(),
            nameof(Account.Telephone2).ToLower(),
            nameof(Account.Telephone3).ToLower()
        };

        public string[] Fields => FIELDS;
    }
}

Above code adds the IPhonenumberChanged to the Account entity. The Account class is the generated strogly typed proxy class that represents and account, and it extends the Microsoft.Xrm.Sdk.Entity, so there is no need to implement the Attributes getter, because it is already there. Finally lets create the plugin that tires the things together.


namespace Kipon.PluginExample.Plugins.Account.v3
{
    public class AccountPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Account target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }
    }
}

Because we have Entities.Account as a parameter, and the name of the parameter is target the Kipon.Solid plugin frameworks knows that this method should be called for the account entity on pre create, and the target should be parsed to this method, and finally we ask for an implementation of washService, so we can call the method with our target as parameter.

Same pattern could now be applied to Contact, Lead, phonecall etc. but to that would involve a lot of copy-paste, and as such not be so solid at all.

The main finding in this part of the article, is that instance interface (Models.IPhonenumberChanged) has been setup as the minimum required functionality a class needs to provide to get phone numbers aligned, and the implementation make no assumptions beside what is exposed in the interface, when solving the assignment. If Lead, Contact, Phonecall, Opportunity or any other entity implements the Models.IPhonenumberChanged it will be able to solve the assignment for that entity as well in a consistent manner.

You might say, but what about update, we are only looking at the create event in above example. Please continue read. The update event is a perfect example to illostrate interface segregation, why it is relevant, and now the Kipon.Solid plugin platform supports such design decissions.

Interface segregation

Basically this rule is about being precise when defining interfaces, and don't put things on your plate unless you are going to eat it. Many small interfaces is better than one big.

Lets take the AccountPlugin from above and add the update functionality. It should be straight forward, just add this snipper to the class:


namespace Kipon.PluginExample.Plugins.Account.v4
{
    public class AccountPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Account target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }

        public void OnPreUpdate(Entities.Account target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }
    }
}

;
It will work, but it is a bad design.

The bad thing is that we ask for more than we need. By added in target parameter of type Account to our OnPreUpdate method, we tell the Kipon.Solid framework, that this method should be called whenver a change is made to an account record. But is that really required. Should our phone wash algoritm check in, if we only change the name of and account, or the budget-revenue. The short answer is ofcause no, it should only check in whenever a phone number i changed. But how should the framework know. You have to explain this detail to the framework.

So lets go back the the implementation on account, and express this rule in an explicit manner:


namespace Kipon.PluginExample.Entities
{
    public partial class Account : Account.IPhonenumberChanged
    {
        public interface IPhonenumberChanged : 
            Models.v3.IPhonenumberChanged, 
            IAccountTarget
        {
            string Telephone1 { get; }
            string Telephone2 { get; }
            string Telephone3 { get; }
        }
    }
}

Take a look a above extension of the account record. First of all we create in interface inside the class. I will call this a jummy jummy interface, because the purpose of the interface is to define witch part of the account we are interested in. We let it extend the Models.IPhonenumberChanged interface and the Entities.IAccountTarget interface. The later is specific to the Kipoon.Solid platform. It is an empty interface that has the single purpose of stating that any class implementing this interface should be considered an account target. Then we define the 3 telephone numbers as simple getter properties, and instead of letting Account implement Models.IPhonenumberChanged interface it now implementes the Account specific Account.IPhonnumberChanged interface. Now lets adjust our plugin method:


namespace Kipon.PluginExample.Plugins.Account.v5
{
    public class AccountPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Account.IPhonenumberChanged target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }

        public void OnPreUpdate(Entities.Account.IPhonenumberChanged target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }
    }
}

As you can see, now we inject the jummy jummy interface, instead of the Account entity implementation. We are asking for a specific interface with explicit properties, instead of an actual implementation of something. The interface defines excatly what we need to know. No more, now less.

You could state that this is jaloua (just another layer of useless abstraction) but that is not the case. First of all, the interface provides type safety to our solution. There is no way we can point to a wrong field with hardcoded string etc. with this approach. Everything is checked compileteme. Secondly the interface is providing valuable information to the Kipon.Solid framework, that this OnPreUpdate method is ONLY relevant if at least one of the phone numbers is changed. This plugin will not be triggered if you just change the name or any other field that is unrelated to phonenumbers. This "only call me if i am relevant", is drained all the way down to the underlying Dynamics 365 plugin infrastructure, ofloading the system, and is thereby given better performance. Finally we have the maintanance task. What if we add one more phonenumber. Such change will be extreamply easy. Just add tne field name to the FIELDS string list, and add the extra property to the Account.IPhonnumberChanged interface. Build and deploy. That's it. Now your update plugin is listening for the additional field, and it will have exact same behavior as the existing fields.

On top of that, we have create a solid phone wash service that can be used cross other entities.

Dependency inversion

Dependency inversion is basically about hiding all dependencies behind abstractions. So any method in a given class should take abstractions as parameters, and these abstractions should have no dependencies to actual implementations, finally the abstractions should clearly define the intentions by explict interfaces. Lets take a look at the last example of implementing the Account plugin to support washing phone numbers:


namespace Kipon.PluginExample.Plugins.Account.v5
{
    public class AccountPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Entities.Account.IPhonenumberChanged target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }

        public void OnPreUpdate(Entities.Account.IPhonenumberChanged target, ServiceAPI.v3.IPhoneWashService washService)
        {
            washService.Wash(target);
        }
    }
}

The first parameter target has a strong relationship to the entity proxy class for Account. In most cases this type of relations to an actual implementation is acceptable in a Dynamics 365 library, because the plugin method is only intended to work for that single entity proxy class. But in this paticular case, if we wish to have similar functionality for Contact, Lead, PhoneCall etc. then i need to create a similar interface, and a similar plugin for all these cases. That is actually a lot of copy paste, and the problem araise from the exact problem that Dependency inversion is trying to address. When our dependencies rely on actual implementations, they get harder to reuse.

But different entities has different numbers of phonenumber fields, and they might even be named different, so how can we accomplish this. The short answer is, that the IPhonenumberChanged interface must state that its intention is to be implemented by an entity, and it serves as the target for a plugin pipeline.


namespace Kipon.PluginExample.Models.v6
{
    public interface IPhonenumberChanged : Kipon.Xrm.ITarget
    {
        string[] Fields { get; }
        Microsoft.Xrm.Sdk.AttributeCollection Attributes { get; }
    }
}

As you can see, our IPhonenumberChanged interface now extends Kipon.Xrm.ITarget. That interface is actually empty has has the single purpose of declaring that anything implementing the IPhonenumberChanged interface is expected to be the target og a plugin pipeline (and implicitly an entity when we are in a Dynamics 365 context).

Now lets go back to the Account entity and implement this version of the interface instead:


using Kipon.Xrm.Extensions.Sdk;
using Kipon.Xrm.Attributes;
namespace Kipon.PluginExample.Entities
{
    [TargetFilter(
        typeof(Models.v6.IPhonenumberChanged), 
        nameof(Account.Telephone1), 
        nameof(Account.Telephone2), 
        nameof(Account.Telephone3))]
    public partial class Account : Models.v6.IPhonenumberChanged
    {
        string[] Models.v6.IPhonenumberChanged.Fields 
            => this.TargetFilterAttributesOf(typeof(Models.v6.IPhonenumberChanged));
    }
}

The account now implement the generic interface again, and no more "strage" extra interface tiring directly to the account class.

By using the generic abstraction Kipon.Xrm.ITarget we stated that IPhonenumberChanged would always represent a target in the pipeline, but we did not take any explicit decission on witch entities.

Finally we solved the "witch attributes are relevant in the IPhonenumberChanged context", by decorating the Account class with a Kipon.Xrm.Attributes.TargetFilterAttribute.

When Kipon.Xrm.Attributes.TargetFilterAttribute is decorated on a class, the class is expected to extend Microsoft.Xrm.Sdk.Entity, and you must parse in the interface of relevance as first parameter to define, when IPhonenumberChanged is the interface, the following attributes are relevant for this specific entity.

Finally we implemented the "Fields" property of the IPhonenumberChanged interface by taking advantage of some kipon.xrm extension methods. That little helper method "TargetFilterAttributesOf" ensures that any addition to the list of fields to listen on in the TargetFilterAttributes, will also reflect in the fields that should be handle by our PhonenumberChanged service.

Now lets implement the ultimate plugin that can wash our telephone numbers. There is no longer anything specific to the account, so instead of putting the code in the AccountPlugin, i will add it as a generic plugin. in the library, ex. under a folder called "Generic"


namespace Kipon.PluginExample.Plugins.Generic
{
    public class PhonenumberwashPlugin : Kipon.Xrm.BasePlugin
    {
        public void OnPreCreate(Models.v6.IPhonenumberChanged target, ServiceAPI.v6.IPhoneWashService washService)
        {
            washService.Wash(target);
        }

        public void OnPreUpdate(Models.v6.IPhonenumberChanged target, ServiceAPI.v6.IPhoneWashService washService)
        {
            washService.Wash(target);
        }
    }
}

As you can see, the plugin no longer has relation to a specific entity proxy implementation. Anything that implemented the IPhonenumberChanged will do.

To wrap up, lets ensure that we also wash phone numbers on the contact entity:


using Kipon.Xrm.Extensions.Sdk;
using Kipon.Xrm.Attributes;

namespace Kipon.PluginExample.Entities
{
    [TargetFilter(
        typeof(Models.v6.IPhonenumberChanged),
        nameof(Contact.Telephone1),
        nameof(Contact.Telephone2),
        nameof(Contact.Telephone3),
        nameof(Contact.MobilePhone)
        )]
    public partial class Contact : Models.v6.IPhonenumberChanged
    {
        public string[] Fields => this.TargetFilterAttributesOf(typeof(Models.v6.IPhonenumberChanged));
    }
}

As you can see, the implementation is very similar to the Account, but for contacts we also have the MobilePhone number. Do we need to add plugin code. The short answer is no. when above code is in place, the only thing we need to do is use the Kipon.Solid plugin deploy tool, and this tool will ensure that our PhonenumberwashPlugin now listen to both Account and Contact. Everything is loosely coupled, it is very easy to create another implementation of the IPhonenumberChanged interface, ex. for Phonecall or Lead, the actual implementation is 100% centralized.

Conclusion

This article has outline the very basic princips of SOLID and has given simple examples on what to consider in regards to each princip, and finally how the Kipon.Solid plugin frameworks helps you stay SOLID when building plugins for the Dynamics 365 platform. This article is not exhaustive in terms of SOLID, nor in terms of Plugin development and it does not describe all the details of the Kipon.Solid plugin frameworks and its posibility. Diig into Api and Examples for more, and most important, start get your hands dirty by doing your own development. After all Kipon.Solid is just a starting point.

© Kipon ApS 2020, 2021, 2022, 2023. All content on the page is the property of Kipon ApS. Any republish or copy of this content is a violation. The content of this site is NOT open source, and cannot be copied, republished or used in any context without explcit permission from the owner.