# Migrating a legacy StorageInitializationModule to Automatic Storage Path Mapping

This guide describes how to migrate an Xperience by Kentico project from a hand-written storage module (commonly `StorageInitializationModule`) to the registry-driven **Automatic Storage Path Mapping** system and its `AddXperienceCloudStoragePathMapping()` entry point.

It is self-contained: all the concepts and API members needed for the migration are described below.

---

## Background

Older Xperience by Kentico projects wire up shared storage by hand. A custom module (typically named `StorageInitializationModule`) calls `StorageHelper.MapStoragePath(path, provider)` once per path, branches on the hosting environment, and picks a storage provider for each path. Every Xperience update that adds a new system path forces a manual edit to this module.

The current system is registry-driven. Platform modules register their own paths automatically; the application only declares its hosting model with a single call in `Program.cs`. For an Xperience Cloud application the whole module usually collapses to:

```csharp
builder.Services.AddXperienceCloudStoragePathMapping();
```

The migration is essentially the analysis that turns the old module into the correct `AddXperienceCloudStoragePathMapping()` call — plus recognizing the minority of cases where part of the module must be kept.

### Key concepts

- **Storage provider** — handles file I/O for a path subtree. `AzureStorageProvider.Create()` = Azure Blob; `StorageProvider.CreateFileSystemStorageProvider()` = local disk.
- **`PathType`** (enum in `CMS.IO`) — declares the intent of a path so the system routes it correctly:
  - `SharedPersistent` — must be shared across instances and durable (media, content item assets, form files).
  - `SharedTemp` — shared across instances but transient (e.g. content sync temp files).
  - `LocalOnly` — instance-local, never shared (caches, temp).
- **`ContainerName`** — in cloud, the Azure Blob container; in local dev, a subdirectory under the local assets root. One value, both uses — they must match so the deployment pipeline can transfer files.
- **`StorageAssetsFolderName`** — the local-dev root directory for deployment-packaged paths (default `"$StorageAssets"`). Global; there is only one.

### When this migration applies

This guide applies only when both hold:

1. The project is hosted on **Xperience Cloud** and the old module gates its Azure mapping on Kentico cloud-environment helpers — `IsQa()`, `IsUat()`, `IsEnvironment(CloudEnvironments.Custom)`, `IsEnvironment(CloudEnvironments.Staging)`, `IsProduction()`, or the combined `IsCloudEnvironment()` (or a subset).
2. The project references the `Kentico.Xperience.Cloud` package.

If the module gates on a generic check instead (e.g. `!IsDevelopment()` only) or a custom condition with no cloud-environment helpers, this guide does not apply — self-hosted Azure App Service uses a different API, `AddAppServiceStoragePathMapping()`.

---

## Step 1 — Locate the module

Find the `StorageInitializationModule.cs` file in the project (or whatever the custom storage module is named — a `Module` that calls `StorageHelper.MapStoragePath`). If several candidates exist, identify which one performs the storage mapping before continuing.

## Step 1a — Check for unrelated code in the module file

The module file may also contain helper types (services, interfaces, options) that are **not** part of the storage-mapping pattern. Before rewriting or deleting the file, grep the solution for every public type it declares. If any type is referenced **outside** this file, do not delete it — move it to its own file or preserve it. Only the `Module` subclass and its private helpers are in scope for this migration.

## Step 2 — Reconstruct the per-path mapping table

The migration hinges on understanding exactly what the old module does. Build a table with **one row per `StorageHelper.MapStoragePath(path, provider)` call**. For each call, resolve:

1. **The path value** — literal, constant, or variable. Trace it to its concrete value (e.g. `~/assets/media/`).

2. **The provider type:**

   | Construction | Meaning |
   |---|---|
   | `AzureStorageProvider.Create()` | Azure Blob storage |
   | `CreateFileSystemStorageProvider()` with `CustomRootPath` set | Local filesystem, custom root (deployment packaging) |
   | `CreateFileSystemStorageProvider()` with no `CustomRootPath` (or `CreateFileSystemStorageProvider(true)`) | Local filesystem, no custom root (excluded from shared storage) |

3. **The `CustomRootPath` value** — the Azure container name, or the local root for filesystem providers. Follow the indirection:
   - Literal: `provider.CustomRootPath = "my-container"`
   - Constant: resolve it
   - Interpolation: `$"{LOCAL_ROOT}/{CONTAINER_NAME}"` — resolve each part
   - Set inside a helper that takes a parameter — trace the argument at each call site
   - Different per call site — capture each one

4. **The environment branch** — cloud / dev / unconditional.

Result:

| Path | Environment | Provider | CustomRootPath |
|------|-------------|----------|----------------|
| `~/assets/` | cloud | Azure | `"default"` |
| `~/assets/synchronizations/` | cloud | FileSystem (no root) | — |
| `~/assets/media/` | dev | FileSystem | `"$StorageAssets/default"` |
| `~/assets/media/` | cloud | Azure | `"media-files"` |

Everything downstream is read off this table.

## Step 3 — Derive container name and local root

**Container name(s)** — from the Azure rows:

- All Azure paths share the **same** `CustomRootPath` → single `ContainerName` (omit it entirely if the value is `"default"`).
- Different Azure paths use **different** values → per-path container overrides via `ConfigureContainerForPath`.

**Local root(s)** — from filesystem-with-custom-root rows, take the part before `/{containerName}`:

- All dev paths share the **same** root → single `StorageAssetsFolderName` (omit if it is the default `"$StorageAssets"`).
- Different dev paths use **different** roots → **not expressible** by the new API (only one global `StorageAssetsFolderName`). Those paths require a retained module — see Step 6.

## Step 4 — Separate platform paths from custom paths

Platform paths are registered automatically by their owning modules and must **not** be registered again. The following are platform-managed (match by exact path or prefix):

| Path | Owner |
|---|---|
| `~/assets/media/` (or `~/assets/media`) | media library files |
| `~/assets/contentitems/` | content item assets |
| `~/assets/bizformfiles/` | biz form file attachments |
| `~/assets/synchronizations/` | content synchronization temp files |
| `~/assets/aira/` | AIRA assets |
| Broad umbrella paths like `~/assets/` | covered all of the above |

Any path **not** in this list is a **custom path** needing an explicit `AddStoragePathRegistration()` call (Step 5b). Paths that match a platform concept but live at a **non-standard location** (e.g. `~/BizFormFiles` instead of `~/assets/bizformfiles/`) are effectively custom and need registering too.

## Step 5 — Apply the migration in Program.cs

### 5a — Add or update the mapping call

Add the mapping call to the service-registration block in `Program.cs`, near other `builder.Services.Add*` calls. If a call already exists, replace it wholesale with the correct form derived from the table.

**Single container, default name (`"default"`)** — no options needed:

```csharp
using CMS.IO;
using Kentico.Xperience.Cloud;

builder.Services.AddXperienceCloudStoragePathMapping();
```

**Single container, non-default name:**

```csharp
builder.Services.AddXperienceCloudStoragePathMapping(options =>
{
    options.ContainerName = "<container-name>";
});
```

**Multiple containers** — use `ConfigureContainerForPath` with the path-identification extension methods from `CMS.IO.Extensions`, which avoid fragile string comparisons:

```csharp
using CMS.IO;
using CMS.IO.Extensions;
using Kentico.Xperience.Cloud;

builder.Services.AddXperienceCloudStoragePathMapping(options =>
{
    options.ContainerName = "<default-container>";
    options.ConfigureContainerForPath = (reg, setup) =>
    {
        if (reg.IsMediaLibraryPath())
        {
            setup.ContainerName = "<media-container>";
        }
        // add further per-path conditions as needed
    };
});
```

Available extension methods (all in `CMS.IO.Extensions`): `IsMediaLibraryPath()`, `IsContentItemAssetPath()`, `IsContentItemAssetTempPath()`, `IsBizFormFilesPath()`, `IsBizFormTempFilesPath()`, `IsSynchronizationPath()`, `IsAiraPath()`.

For a **custom** path that needs a non-default container, match on `reg.RegisteredPath` against the **same constant** used in its `AddStoragePathRegistration()` call (and in the retained module, if any) — the strings must be identical or the override silently won't apply:

```csharp
options.ConfigureContainerForPath = (reg, setup) =>
{
    if (reg.RegisteredPath == StorageInitializationModule.LUCENE_PATH)
    {
        setup.ContainerName = "lucene";
    }
};
```

**Non-default local root** — if the dev branch used a root other than `"$StorageAssets"`:

```csharp
options.StorageAssetsFolderName = "<local-root>";
```

If different paths used different local roots, omit this (keep the default) and handle those paths with a retained module — Step 6.

### 5b — Register custom paths

For each custom path from Step 4, add one `AddStoragePathRegistration()` call right after the mapping call. There is no batch overload — one call per path:

```csharp
using CMS.IO;

builder.Services.AddStoragePathRegistration("<custom-path>", PathType.SharedPersistent);
```

`PathType` follows how the old module mapped the path:

| `PathType` | Old mapping |
|---|---|
| `SharedPersistent` | Mapped to Azure — shared and durable |
| `SharedTemp` | Mapped to Azure but holds transient cross-instance files |
| `LocalOnly` | Mapped to local filesystem with no custom root |

**Mixed cloud/dev paths.** A path can be Azure in cloud but local-with-no-custom-root in dev (e.g. a search index). Register it for the **cloud** behaviour with its cloud `PathType` (`SharedPersistent` if it went to Azure), **and** retain its dev mapping in the module (gate (b) in Step 6) — otherwise the new API relocates it under `StorageAssetsFolderName` in dev instead of leaving it at its natural location.

`AddStoragePathRegistration()` cannot configure local deployment packaging. If a custom path was previously mapped to a local custom root for packaging, register it here for the cloud mapping **and** keep its dev-environment local mapping in a retained module (Step 6).

## Step 6 — Decide whether the module survives

Before deleting anything, evaluate this gate. **RETAIN (and strip) the module if ANY mapping matches one of these — otherwise delete it:**

- (a) A path whose dev local root differs from the global `StorageAssetsFolderName` (per-path local root).
- (b) A path that is Azure-in-cloud but local-with-no-custom-root in dev (see the "Mixed cloud/dev paths" note in Step 5b).
- (c) A non-default provider setting with no option equivalent (custom CDN prefix, `PublicExternalFolderObject = true`, etc.). A setting left at its **default** value (e.g. `PublicExternalFolderObject = false`) is a no-op — ignore it, do not retain for it.
- (d) Any other configuration incompatible with `AddXperienceCloudStoragePathMapping()`.

If the gate is all-clear, delete `StorageInitializationModule.cs`. A project may hit several of these at once — retention is not rare.

### Rewriting a retained module

Keep only the inexpressible mappings; everything else moves to `Program.cs`.

1. Declare one path per affected mapping in SCREAMING_SNAKE_CASE. Use `public const string` for literal paths; use `public static readonly string` when the path is built at runtime (e.g. `Path.Combine(...)`), which cannot be a compile-time `const`. `Program.cs` references this member in its `AddStoragePathRegistration()` call, so the path string lives in one place.
2. Keep only the private constants the retained mappings need (local roots, container names). Remove the rest.
3. In `OnInit`, resolve only the services the retained mappings require and keep only the mapping logic the new API can't express.

The `[assembly: RegisterModule(...)]` attribute must stay. Without it the module is never registered and the retained mapping silently does nothing — the build still succeeds, so a build check will not catch the omission.

Gate the dev mapping on `!environment.IsCloudEnvironment()` so it does not run on Staging/Custom cloud environments:

```csharp
using CMS;
using CMS.Core;
using CMS.DataEngine;
using CMS.IO;

using Kentico.Xperience.Cloud;

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

[assembly: RegisterModule(typeof(MyProject.StorageInitializationModule))]

namespace MyProject
{
    public class StorageInitializationModule : Module
    {
        public const string MEMBER_ASSETS_PATH = "~/member-assets/";


        private const string LOCAL_STORAGE_MEMBER_ASSETS_ROOT = "$StorageMemberAssets";
        private const string CONTAINER_NAME_DEFAULT = "default";


        public StorageInitializationModule() : base(nameof(StorageInitializationModule)) { }


        protected override void OnInit(ModuleInitParameters parameters)
        {
            base.OnInit(parameters);

            var environment = parameters.Services.GetRequiredService<IWebHostEnvironment>();

            if (!environment.IsCloudEnvironment())
            {
                var provider = StorageProvider.CreateFileSystemStorageProvider();
                provider.CustomRootPath = $"{LOCAL_STORAGE_MEMBER_ASSETS_ROOT}/{CONTAINER_NAME_DEFAULT}";
                StorageHelper.MapStoragePath(MEMBER_ASSETS_PATH, provider);
            }
        }
    }
}
```

The matching registration in `Program.cs` then references the constant rather than a literal:

```csharp
builder.Services.AddStoragePathRegistration(StorageInitializationModule.MEMBER_ASSETS_PATH, PathType.SharedPersistent);
```

The path is registered in `Program.cs` for the cloud mapping; the retained module handles only the dev-environment local mapping the new API cannot.

**Required `using` directives for the retained module:** `CMS` (`RegisterModule`), `CMS.Core` (`ModuleInitParameters`), `CMS.DataEngine` (`Module`), `CMS.IO` (`StorageHelper`, `StorageProvider`), `Kentico.Xperience.Cloud` (`IsCloudEnvironment` and other environment helpers), `Microsoft.AspNetCore.Hosting` (`IWebHostEnvironment`), `Microsoft.Extensions.DependencyInjection` (`GetRequiredService`). If the retained mapping gates on individual environments via `IsEnvironment(...)`/`IsProduction()` instead of `IsCloudEnvironment()`, also add `using Microsoft.Extensions.Hosting;`.

## Step 7 — Verify the build

```bash
dotnet build <path-to-project>.csproj --no-restore
```

Common failures and fixes:

- Missing `using` in `Program.cs` (`CMS.IO`, `CMS.IO.Extensions`, or `Kentico.Xperience.Cloud`).
- A leftover reference to the deleted module type elsewhere in the project.
- `Kentico.Xperience.Cloud` not referenced in the `.csproj`.

A green build does **not** prove a retained module is wired up — the `[assembly: RegisterModule(...)]` attribute must be confirmed present.

**Coverage check (do this before declaring done).** List every `MapStoragePath` call from the Step 2 table. Assert each is now covered by exactly one of: platform auto-registration, an `AddStoragePathRegistration()` call, or a retained-module mapping — and that none is covered twice (double-mapping). A green build does not verify this; the table does.

---

## Worked example

Legacy module:

```csharp
[assembly: RegisterModule(typeof(StorageInitializationModule))]

public class StorageInitializationModule : Module
{
    private const string CONTAINER = "default";
    private const string MEDIA_CONTAINER = "media-files";
    private const string LOCAL_ROOT = "$StorageAssets";

    public StorageInitializationModule() : base(nameof(StorageInitializationModule)) { }

    protected override void OnInit(ModuleInitParameters parameters)
    {
        base.OnInit(parameters);

        var environment = parameters.Services.GetRequiredService<IWebHostEnvironment>();

        if (environment.IsCloudEnvironment())
        {
            StorageHelper.MapStoragePath("~/assets/", AzureStorageProvider.Create());        // CONTAINER
            StorageHelper.MapStoragePath("~/assets/media/", AzureStorageProvider.Create());  // MEDIA_CONTAINER
        }
        else
        {
            var assets = StorageProvider.CreateFileSystemStorageProvider();
            assets.CustomRootPath = $"{LOCAL_ROOT}/{CONTAINER}";
            StorageHelper.MapStoragePath("~/assets/", assets);

            var media = StorageProvider.CreateFileSystemStorageProvider();
            media.CustomRootPath = $"{LOCAL_ROOT}/{MEDIA_CONTAINER}";
            StorageHelper.MapStoragePath("~/assets/media/", media);
        }
    }
}
```

Table:

| Path | Environment | Provider | CustomRootPath |
|---|---|---|---|
| `~/assets/` | cloud | Azure | `"default"` |
| `~/assets/media/` | cloud | Azure | `"media-files"` |
| `~/assets/` | dev | FileSystem | `"$StorageAssets/default"` |
| `~/assets/media/` | dev | FileSystem | `"$StorageAssets/media-files"` |

Analysis: two containers (`default`, `media-files`) → `ConfigureContainerForPath`. Both dev roots are `$StorageAssets` → default `StorageAssetsFolderName`, omit it. Both paths are platform-managed → no `AddStoragePathRegistration()` calls. Nothing inexpressible → delete the module.

Result in `Program.cs`:

```csharp
using CMS.IO.Extensions;
using Kentico.Xperience.Cloud;

builder.Services.AddXperienceCloudStoragePathMapping(options =>
{
    options.ContainerName = "default";
    options.ConfigureContainerForPath = (reg, setup) =>
    {
        if (reg.IsMediaLibraryPath())
        {
            setup.ContainerName = "media-files";
        }
    };
});
```

---

## Worked example 2 — retained module

Legacy module (paths resolved through a helper service):

```csharp
// ShouldMapAzureStorage => IsQa() || IsUat() || IsProduction()
// Xperience: "~/assets/"               cloud "default"  | dev "$StorageAssets/default"
// Member:    "~/member-assets/"        cloud "default"  | dev "$StorageMemberAssets/default"
// Lucene:    "~/App_Data/LuceneSearch/" cloud "lucene"  | dev (FileSystem, no custom root)
```

Table:

| Path | Environment | Provider | CustomRootPath |
|---|---|---|---|
| `~/assets/` | cloud | Azure | `"default"` |
| `~/member-assets/` | cloud | Azure | `"default"` |
| `~/App_Data/LuceneSearch/` | cloud | Azure | `"lucene"` |
| `~/assets/` | dev | FileSystem | `"$StorageAssets/default"` |
| `~/member-assets/` | dev | FileSystem | `"$StorageMemberAssets/default"` |
| `~/App_Data/LuceneSearch/` | dev | FileSystem (no root) | — |

Analysis:

- Containers: `default` (Xperience + Member) and `lucene` → default is `default` (omit `ContainerName`); `lucene` is a custom-path override via `ConfigureContainerForPath`.
- Local roots: Xperience `$StorageAssets` (= global default, omit `StorageAssetsFolderName`); Member `$StorageMemberAssets` → **gate (a)**; Lucene Azure-in-cloud / no-root-in-dev → **gate (b)**. Both must be retained.
- Platform vs custom: `~/assets/` is the umbrella platform path (no registration); `~/member-assets/` and `~/App_Data/LuceneSearch/` are custom (`SharedPersistent`, both went to Azure).

Result in `Program.cs`:

```csharp
using CMS.IO;
using Kentico.Xperience.Cloud;

builder.Services.AddXperienceCloudStoragePathMapping(options =>
{
    options.ConfigureContainerForPath = (reg, setup) =>
    {
        if (reg.RegisteredPath == StorageInitializationModule.LUCENE_PATH)
        {
            setup.ContainerName = "lucene";
        }
    };
});

builder.Services.AddStoragePathRegistration(StorageInitializationModule.MEMBER_ASSETS_PATH, PathType.SharedPersistent);
builder.Services.AddStoragePathRegistration(StorageInitializationModule.LUCENE_PATH, PathType.SharedPersistent);
```

Stripped retained module (only the inexpressible dev mappings survive):

```csharp
using CMS;
using CMS.Core;
using CMS.DataEngine;
using CMS.IO;

using Kentico.Xperience.Cloud;

using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;

using Path = CMS.IO.Path;

[assembly: RegisterModule(typeof(DancingGoat.StorageInitializationModule))]

namespace DancingGoat;

public class StorageInitializationModule : Module
{
    public const string MEMBER_ASSETS_PATH = "~/member-assets/";
    public static readonly string LUCENE_PATH = $"~/{Path.Combine("App_Data", "LuceneSearch")}/";


    private const string LOCAL_STORAGE_MEMBER_ASSETS_ROOT = "$StorageMemberAssets";
    private const string CONTAINER_NAME_DEFAULT = "default";


    public StorageInitializationModule() : base(nameof(StorageInitializationModule)) { }


    protected override void OnInit(ModuleInitParameters parameters)
    {
        base.OnInit(parameters);

        var environment = parameters.Services.GetRequiredService<IWebHostEnvironment>();

        if (!environment.IsCloudEnvironment())
        {
            // Member assets use a local root that differs from the global StorageAssetsFolderName.
            var memberProvider = StorageProvider.CreateFileSystemStorageProvider();
            memberProvider.CustomRootPath = $"{LOCAL_STORAGE_MEMBER_ASSETS_ROOT}/{CONTAINER_NAME_DEFAULT}";
            StorageHelper.MapStoragePath(MEMBER_ASSETS_PATH, memberProvider);

            // Lucene search index is mapped to the local filesystem with no custom root.
            var luceneProvider = StorageProvider.CreateFileSystemStorageProvider();
            StorageHelper.MapStoragePath(LUCENE_PATH, luceneProvider);
        }
    }
}
```
