GitHub NuGet →
High-Performance .NET Library

String Mapping
Reinvented.

Spinner maps objects to fixed-width strings and back — with compiled delegates, zero-allocation Span APIs and zero reflection at runtime.

79× Faster
26× Less Memory
.NET 8+ Compatible

Blazing Fast

Compiled delegates and expression trees eliminate all reflection overhead at runtime.

🎯

Zero Allocation

Span-based APIs and ThreadStatic StringBuilder minimise GC pressure to near-zero.

🔒

Thread Safe

Designed for high-concurrency — one instance shared across hundreds of threads.

🔌

Extensible

Custom interceptors for complex conversions without sacrificing performance.

📐

Fixed-Width

COBOL-style fixed-width layouts with automatic padding, trimming and truncation.

🔄

Bi-directional

Seamlessly convert between strongly-typed objects and raw string representations.

Getting Started

Spinner targets .NET 8 and above. Install it from NuGet in seconds.

Installation

Package Manager
Install-Package Spinner
.NET CLI
dotnet add package Spinner

Quick Start

Define your model with attributes, then create a Spinner<T> instance:

C#
using Spinner.Attributes;

public class PersonModel
{
    [ObjectMapper(30)]
    public string Name { get; set; }

    [ObjectMapper(8)]
    public string BirthDate { get; set; }
}

// Write: object → fixed-width string
var spinner = new Spinner<PersonModel>();
var person  = new PersonModel { Name = "John Doe", BirthDate = "19900101" };
string result = spinner.WriteAsString(person);
// → "John Doe                      19900101"

// Read: fixed-width string → object
PersonModel parsed = spinner.ReadFromString(result);

Write Object to String

Map strongly-typed .NET objects to fixed-width strings using declarative attributes.

Attributes

Attribute
Parameter
Description
ObjectMapper
length
Defines the field length in the output string
WriteProperty
paddingType
Configures padding direction: Left or Right
WriteProperty
interceptorType
Optional custom IInterceptor<T> for this field

Padding Types

ℹ️
PaddingType.Left is the default — value is aligned left, spaces on right.
Use PaddingType.Right for right-aligned fields (e.g. numeric amounts).
Left Padding (default)
[ObjectMapper(10)]
[WriteProperty(PaddingType.Left)]
public string Name { get; set; }
// "John      "
Right Padding
[ObjectMapper(10)]
[WriteProperty(PaddingType.Right)]
public string Amount { get; set; }
// "      1000"

API

WriteAsString(T obj)

Concatenates all mapped fields into a single string.

→ string
WriteAsSpan(T obj)

Returns a ReadOnlySpan<char> backed by the ThreadStatic buffer — zero extra allocation.

→ ReadOnlySpan<char>

Read String to Object

Parse fixed-width strings back into strongly-typed .NET objects automatically.

Configuration

C#
public class OrderModel
{
    [ObjectMapper(10)]
    [ReadProperty(0)]          // position 0 → chars 0..9
    public string OrderId { get; set; }

    [ObjectMapper(15)]
    [ReadProperty(1)]          // position 1 → chars 10..24
    public decimal Amount { get; set; }

    [ObjectMapper(8)]
    [ReadProperty(2)]          // position 2 → chars 25..32
    public DateTime OrderDate { get; set; }
}

var spinner = new Spinner<OrderModel>();
OrderModel order = spinner.ReadFromString("ORD001    000000099.9920240101");

Supported Types

Spinner automatically converts these primitive types — no interceptor needed:

string int long decimal double float bool char short byte DateTime DateOnly TimeOnly Guid uint ulong

Zero-Allocation Reading

For hot throughput paths, use ReadFromSpan to skip string creation entirely:

C# — Span API
// No string allocation — pass a Span directly from a buffer / memory-mapped file
ReadOnlySpan<char> raw = buffer.AsSpan(0, 33);
PersonModel person = spinner.ReadFromSpan(raw);

Interceptors

Plug custom conversion logic into the pipeline without sacrificing performance.

Implement IInterceptor<T> to override how a single field is read or written:

C# — Currency Interceptor
public class CurrencyInterceptor : IInterceptor<decimal>
{
    // "0000001234" → 12.34
    public decimal Read(ReadOnlySpan<char> value)
    {
        if (long.TryParse(value, out var raw))
            return raw / 100m;
        return 0m;
    }

    // 12.34 → "0000001234"
    public string Write(decimal value)
        => ((long)(value * 100)).ToString("D10");
}

public class PaymentModel
{
    [ObjectMapper(10)]
    [ReadProperty(0, typeof(CurrencyInterceptor))]
    [WriteProperty(PaddingType.Right, typeof(CurrencyInterceptor))]
    public decimal Amount { get; set; }
}
💡
Performance: Interceptors are cached as compiled delegates on first use. Benchmarks show up to 79× improvement over naive reflection approaches even with custom interception active.

Common Use Cases

01

Currency Formatting

Convert integer cents to decimal amounts and back.

02

Custom Date Formats

Parse non-standard date representations like YYYYMMDD.

03

Boolean Mapping

Map "S"/"N" or "1"/"0" to bool values.

04

Enum Conversion

Map status codes like "AP" or "RJ" to typed enums.

Advanced Features

Thread Safety

Fully thread-safe. A single Spinner<T> instance can be shared across hundreds of concurrent threads with no locks.

All write-side mutable state lives in [ThreadStatic] StringBuilder fields — each thread gets its own buffer. Property metadata is cached in a ConcurrentDictionary on first access and never mutated afterward.

C# — Singleton / DI
// ASP.NET Core — register as Singleton
builder.Services.AddSingleton<Spinner<PersonModel>>();

// Or use a static readonly field
private static readonly Spinner<PersonModel> _spinner = new();

Inspecting the Mapper

Use GetObjectMapper to retrieve the mapping metadata at runtime:

C#
var spinner = new Spinner<PersonModel>();
ObjectMapper[] mappers = spinner.GetObjectMapper;

foreach (var m in mappers)
    Console.WriteLine($"Field length: {m.Lenght}");

Performance Benchmarks

Measured on Windows 11 · 12th Gen Intel Core i5-1235U · .NET 8 / 9 / 10

79× Faster with Interceptors
60× Faster Read Operations
26× Less Memory Allocated
0 B Alloc with Span APIs

Read Benchmarks

Method
Version
Mean (μs)
Alloc (B)
Delta
ReadFromString
v1.x
15,234.2
8,432
baseline
ReadFromString
v2.0
253.1
324
60× faster
ReadFromSpan
v2.0
198.7
0
76× / 0 B

Write Benchmarks

Method
Version
Mean (μs)
Alloc (B)
Delta
WriteAsString
v1.x
8,921.5
12,800
baseline
WriteAsString
v2.0
287.4
492
31× faster

Interceptor Benchmarks

Method
Version
Mean (μs)
Alloc (B)
Delta
ReadWithInterceptor
v1.x
42,108.0
18,920
baseline
ReadWithInterceptor
v2.0
533.1
720
79× faster

Best Practices

01

Reuse Instances

Create one Spinner<T> and reuse it everywhere — property metadata is compiled and cached after first use.

02

Prefer Span APIs

Use ReadFromSpan and WriteAsSpan on hot paths to achieve true zero-allocation throughput.

03

Avoid Per-Request Creation

Never instantiate Spinner<T> inside loops or per-request code — that discards cached delegates.

04

DI as Singleton

In ASP.NET Core, register Spinner<T> as a Singleton so the cache is built once and shared.

Migration Guide v2.0

Upgrading from v1.x brings massive performance gains with minimal breaking changes.

⚠️
v2.0 contains breaking changes. Review the items below before upgrading.

Breaking Changes

Spinner is now a sealed class

v1.x — ref struct
// Could NOT be stored in a field
var spinner = new Spinner<T>(value);
v2.0 — sealed class
// Storable, injectable, singleton-safe
var spinner = new Spinner<T>();

Read method signatures simplified

v1.x
T result = spinner.ReadFromString<T>(value);
v2.0
T result = spinner.ReadFromString(value);

Removed properties

GetWriteProperties Use GetObjectMapper
GetReadProperties Use GetObjectMapper

New in v2.0

NEW Sealed class — storable in fields & DI
NEW Compiled delegate property caching
NEW ThreadStatic StringBuilder for writes
NEW ReadFromSpan zero-allocation API
NEW Static cache shared across all instances
NEW AggressiveOptimization on all hot paths

Improvement Summary

Operation
Speed
Memory
Read (string)
60× faster
26× less
Read (span)
76× faster
0 B allocated
Write
31× faster
26× less
Interceptors
79× faster
26× less