9
\$\begingroup\$

Problem Description

I am working on an e-commerce website, when a user wants to sell a product, he would open the product page and he can upload up to 12 photos:

enter image description here

Image Upload Process

This is the process that I follow for saving images. Once user drops an image on the uploader, it would get saved with a temporary prefix and a timespan. So for example when user drops 3 images, I would have:

tmp-0-20190911123456787.jpg
tmp-1-20190911123456777.jpg
tmp-2-20190911123457777.jpg

Now once the user completes the process, he clicks on DONE button, I create an array of 12 strings, representing the latest image names in the 12 uploaders.

Then I open the corresponding folder (where product's temporary images are dropped). I get all images dropped in the folder, if the images is present in the array, I rename it to a permanent image name, if the uploaded image is not present in the array, I would delete the temp image from the upload folder. This is how photos folder looks like once user click on DONE:

img-0-20190911123456787-product-title-is-appended-in-image-name.jpg 
img-1-20190911123456777-product-title-is-appended-in-image-name.jpg
img-2-20190911123457777-product-title-is-appended-in-image-name.jpg

Infrastructure Consideration

I use different file systems to save the image, in case of Dev environment the images are stored in local file system. In case of Prod environment, images are stored in AWS S3 Bucket.

DDD/Architecture Considerations

I want to follow DDD principles and also I want to keep the code DRY. Since I have two different infrastructure layers (S3 & local disk) I want to remove as much logic as possible from the infrastructure layer (so I don't have to repeat the logic in each infrastructure layer).

Since I want min logic in Infrastructure layer, I need to put the logic somewhere else. (But where should this logic go?)

I assume in DDD, domain is a good place for putting the logic, but at the same time, in DDD an entity should not have dependency on infrastructure layer, and here, FileInfo (i.e. System.IO) is the infrastructure layer for Disk I/O/... I am finding it very difficult to think of a domain object which does not include FileInfo or FileStream.

So finally this is how my DDD layers look like:

enter image description here

The Code:

Infrastructure Layer Contract

IImageRepository.cs

This interface defines the contracts the S3 and Disk IO should implement:

public interface IImageRepository
{
    string CreateAdFolder(string folderName);

    List<string> GetPermanentImageNames(string photosUrlPath);

    string UploadTempImage(FileUpload tempImageUpload);

    void SavePermanentImages(List<string> curPhotos, string photosUrlPath, ImageInfo imageInfo);
}

Domain Objects (this is only for Upload Bounded Context)

FileUpload.cs

// when user upload an images, we save the file with a temporary prefix, once user Save the form we rename the temp image name to a permanent image name 
// user may upload a couple of images, each image is uploaded through an Uploader. In case of advertisement we have 12 uploaders on the form, but in case of 
// profile picture, we only have 1 uploader on the form. Regardless of number of uploaders on the form, user may upload several phones on the same uploader 
// befor hitting the save button. When user hits save, we need to delete all the temporary images except the final version when is currently in the uploader
// we need to rename this final images from temp name to a permanent image name
public class FileUpload
{
    public FileUpload(HttpPostedFileBase fileToUpload, string urlPath, string upoaderNumber)
    {
        FileToUpload = fileToUpload;
        UrlPath = urlPath;
        UploaderNumber = upoaderNumber;
    }

    public HttpPostedFileBase FileToUpload { get; }

    public string FileName
    {
        get
        {
            return FileToUpload.FileName;
        }
    }

    public string UrlPath { get; }

    public string UploaderNumber { get; }

    public void ValidateUploaderNumber()
    {
        // we have 12 image uploaders on the screen, and they are associated with 12 files in ad folder, we need to check uploaderNumber is within this range
        if (int.TryParse(UploaderNumber, out int uploaderNumber))
        {
            if (uploaderNumber >= 0 && uploaderNumber <= GlobalConstants.NoOfImagesPerAd)
            {
                return;
            }
        }

        throw new Exception($"Invalid Upload Request: Image Uploader Number: {UploaderNumber}");
    }
}

ImageInfo.cs

// Details that we need for saving an image, these details would saved as the text properties of the image
public class ImageInfo
{
    public ImageInfo(string title, string category)
    {
        Title = title;
        Category = category;
    }

    public string Title;

    public string Category;

    public string GetJPEGTitle()
    {
        return $"{Category}: {Title} - Buy and Sell";
    }

    public string GetJPEGComment()
    {
        return $"Buy and Sell for Free from Shopless {GlobalConfig.CountryLongName}";
    }

    public string GetJPEGCopyrite()
    {
        return $"Shopless {DateTime.Now.Year.ToString()}";
    }
}

UserUploadPath.cs

public class UserPhotosPath
{
    public UserPhotosPath(long userId)
    {
        UserId = userId;
    }

    public long UserId { get; }

    /// <summary>
    /// each user has his own images folder, append folder name under user's images folder
    /// </summary>
    /// <param name="folderName"></param>
    /// <returns>// returns something like: /file-uploads/images/24/$(foldername)</returns>
    public string AppendtoUserPhotosUrlPath(string folderName)
    {
        return $"{GetUserPhotosUrl()}{folderName}";
    }

    public void ValidateUserPermissionToPath(string photosUrlPath)
    {
        if (string.IsNullOrEmpty(photosUrlPath))
        {
            throw new Exception($"Invalid empthry Path, UserId: {UserId}");
        }

        // since photosUrlPath is coming from the browser, a hacker could construct a path with a different userId and upload images to that path
        // here, we need to validate the requested path matches the current user's photo upload folder
        string curUserPath = GetUserPhotosUrl().TrimStart('/');
        string trimmedPhotosUrlPath = photosUrlPath.TrimStart(new char[] { '/' });
        if (trimmedPhotosUrlPath.StartsWith(curUserPath, StringComparison.InvariantCultureIgnoreCase))
        {
            return;
        }

        throw new Exception($"Access denied for UserId: {UserId}, Path: {photosUrlPath}");
    }

    // returns something like: /file-uploads/images/24/
    private string GetUserPhotosUrl()
    {
        return $"{GlobalConfig.ImageUploadRelativeRoot}{GlobalConstants.UrlSeparator}{UserId.ToString()}{GlobalConstants.UrlSeparator}";
    }
}

Common Helper

I could not think of any place to keep this logic (this is for generating temporary and permanent image name) so I have created a static helper class in Common project:

ImageNameHelper.cs

public static class ImageNameHelper
{
    public static string GenerateUniqueAdFolderName()
    {
        return GetIncrementalUniqueString();
    }

    /// <summary>
    /// Permanent images name should be generated using this pattern: img(UploaderNumber)-(dash-separated-title)-(timestamp).(originalFileExtension)
    /// </summary>
    /// <param name="fileName">original/temporary file name</param>
    /// <param name="imageUploaderNumber">image uploader number</param>
    /// <param name="title">Title of the advertisement</param>
    /// <returns>something like this: img1-beautiful-scarf-for-sale-20180423134055768-hashcode.jpg</returns>
    public static string GenerateUniquePermanentImageName(string fileName, string imageUploaderNumber, string title)
    {
        title = title.ToDashSeparatedString();
        if (string.IsNullOrEmpty(title))
        {
            title = "picture";
        }

        return $"{GetPermanentPrefix(imageUploaderNumber)}{title}-{GetIncrementalUniqueString()}{Path.GetExtension(fileName)}";
    }

    /// <summary>
    /// Temporary images name should be generated using this pattern: tmp(uploaderNumber)-(TimeStamp-UniqueCode).(originalFileExtension)
    /// </summary>
    /// <param name="fileName">OrgiginalImageName.jpg</param>
    /// <param name="imageUploaderNumber">uploader number, should be between 0 to 11</param>
    /// <returns>something like this: tmp-1-TimeStamp-Guid.jpg</returns>
    public static string GenerateUniqueTemporaryImageName(string fileName, string imageUploaderNumber)
    {
        return GetTemporarytPrefix(imageUploaderNumber) + GetIncrementalUniqueString() + Path.GetExtension(fileName);
    }

    public static string GetPermanentImagePrefixPattern()
    {
        return GlobalConstants.PermanentPrefix + "*";
    }

    public static string GetTemporaryImagePrefixPatternForUploader(string imageUploaderNumber)
    {
        // all temporary images with uploader 1 should start with tmp-1 (pattern would be "tmp1-*")
        return GetTemporarytPrefix(imageUploaderNumber) + "*";
    }

    public static bool DoesImageHaveCorrectPermanentPrefix(string imageName, string imageUploaderNumber)
    {
        if (imageName.StartsWith(GetPermanentPrefix(imageUploaderNumber), StringComparison.InvariantCultureIgnoreCase))
        {
            return true;
        }

        return false;
    }

    private static string GetPermanentPrefix(string imageUploaderNumber)
    {
        return GlobalConstants.PermanentPrefix + imageUploaderNumber + "-";
    }

    private static string GetTemporarytPrefix(string imageUploaderNumber)
    {
        return GlobalConstants.TemporaryPrefix + imageUploaderNumber + "-";
    }

    // I am using TimeStap to ensure names generated names are incremental and a Guid to ensure they are unique
    private static string GetIncrementalUniqueString()
    {
        return DateTime.Now.ToCompactDateTimeString() + "-" + Math.Abs(Guid.NewGuid().GetHashCode()).ToString();
    }
}

Infrastructure Layer (Disk)

This is the implementation of the contracts for saving images in the file system:

public class DiskImageRepository : IImageRepository
{
    private UserPhotosPath _userPath;
    private DiskPhysicalPathMapper _physicalPathMapper;

    public DiskImageRepository(UserPhotosPath userPath, DiskPhysicalPathMapper physicalPathBuilder)
    {
        _userPath = userPath;
        _physicalPathMapper = physicalPathBuilder;
    }

    public string CreateAdFolder(string folderName)
    {
        string photosUrlPath = _userPath.AppendtoUserPhotosUrlPath(folderName);

        // no need to check if User directory already exists or not, since it would be automatically created if missing
        // Create directory will throw exception if it cannot create the directory
        string physicalPath = _physicalPathMapper.ConvertUrlToPhysicalPath(photosUrlPath);
        Directory.CreateDirectory(physicalPath);
        return photosUrlPath;
    }

    public List<string> GetPermanentImageNames(string photosUrlPath)
    {
        int i = 0;
        List<string> photos = new List<string>(new string[GlobalConstants.NoOfImagesPerAd]); // initialize list to contain 12 elements
        string physicalPath = GetValidatedPhysicalPath(photosUrlPath);
        FileInfo[] images = GetFiles(physicalPath, ImageNameHelper.GetPermanentImagePrefixPattern());

        foreach (FileInfo image in images)
        {
            if (i >= GlobalConstants.NoOfImagesPerAd)
            {
                LogConfig.Logger.Error($"{photosUrlPath} contains more than {GlobalConstants.NoOfImagesPerAd} files, the cause need to be investigated.");
                break;
            }

            photos[i++] = image.Name;
        }

        return photos;
    }

    public string UploadTempImage(FileUpload tempImageUpload)
    {
        // exception is thrown if any of the following validation fails
        _userPath.ValidateUserPermissionToPath(tempImageUpload.UrlPath);
        tempImageUpload.ValidateUploaderNumber();
        var physicalPath = GetValidatedPhysicalPath(tempImageUpload.UrlPath);

        DeleteSuperceededTemporaryImagesByUploaderNumber(physicalPath, tempImageUpload.UploaderNumber);
        var tmpFileName = ImageNameHelper.GenerateUniqueTemporaryImageName(tempImageUpload.FileName, tempImageUpload.UploaderNumber);
        var fullPhysicalPath = Path.Combine(physicalPath, tmpFileName);
        tempImageUpload.FileToUpload.SaveAs(fullPhysicalPath);
        return tmpFileName;
    }

    public void SavePermanentImages(List<string> curPhotos, string photosUrlPath, ImageInfo imageInfo)
    {
        _userPath.ValidateUserPermissionToPath(photosUrlPath);
        var physicalPath = GetValidatedPhysicalPath(photosUrlPath);
        FileInfo[] images = GetFiles(physicalPath);

        for (int i = 0; i < curPhotos.Count; i++)
        {
            if (!string.IsNullOrEmpty(curPhotos[i]))
            {
                if (images.Where(img => string.Equals(img.Name, curPhotos[i], StringComparison.InvariantCultureIgnoreCase)).Any() == false)
                {
                    LogConfig.Logger.Error($"photo: {curPhotos[i]}, was not found in ad folder: {photosUrlPath}. This is either a bug or a malicious request.");
                    curPhotos[i] = string.Empty;
                }
            }
        }

        foreach (FileInfo image in images)
        {
            var index = curPhotos.FindIndex(p => p.Equals(image.Name, StringComparison.InvariantCultureIgnoreCase));
            if (index >= 0)
            {
                if (ImageNameHelper.DoesImageHaveCorrectPermanentPrefix(image.Name, index.ToString()) == false)
                {
                    var permanentImageName = ImageNameHelper.GenerateUniquePermanentImageName(image.Name, index.ToString(), imageInfo.Title);
                    string fullPhysicalPath = Path.Combine(physicalPath, permanentImageName);
                    image.MoveTo(fullPhysicalPath);
                    SetJpegMetadata(fullPhysicalPath, imageInfo);
                    curPhotos[index] = permanentImageName;
                }
            }
            else
            {
                image.Delete();
            }
        }
    }

    private string GetValidatedPhysicalPath(string urlPath)
    {
        string physicalPath = _physicalPathMapper.ConvertUrlToPhysicalPath(urlPath);

        if (!Directory.Exists(physicalPath))
        {
            throw new Exception($"Path does not exists: {physicalPath}");
        }

        return physicalPath;
    }

    // We have 12 images uploader on the screen which correspond to 12 files (place holders) in ad folder, on the disk drive. If an image is 
    // uploaded on uploader 1, we need to delete all other temporary images uploaded on uploader 1. We should not delete permanent images at
    // this stage, because user may not saves changes, and in that case we need to delete all temporary images
    // NOTE: it is considered that path and uploader number are already validated.
    private void DeleteSuperceededTemporaryImagesByUploaderNumber(string adFolderFullPhysicalPath, string uploaderNumber)
    {
        FileInfo[] oldTmpImages = GetFiles(adFolderFullPhysicalPath, ImageNameHelper.GetTemporaryImagePrefixPatternForUploader(uploaderNumber));
        foreach (var oldTmpImage in oldTmpImages)
        {
            oldTmpImage.Delete();
        }
    }

    private FileInfo[] GetFiles(string physicalPath, string searchPattern = "")
    {
        if (string.IsNullOrEmpty(searchPattern))
        {
            searchPattern = "*";
        }

        DirectoryInfo di = new DirectoryInfo(physicalPath);
        return di.GetFiles(searchPattern).OrderBy(x => x.Name.PadNumbersForAlphanumericSort()).ToArray();
    }

    private void SetJpegMetadata(string physicalPathToJpeg, ImageInfo imageInfo)
    {
        var jpeg = new JpegMetadataAdapter(physicalPathToJpeg);
        jpeg.Metadata.Title = imageInfo.GetJPEGTitle();
        jpeg.Metadata.Comment = imageInfo.GetJPEGComment();
        jpeg.Metadata.Copyright = imageInfo.GetJPEGCopyrite();
        jpeg.Save();
    }
}

DiskPhysicalPathMapper.cs

This is just a mapper to translate URL paths to Physical Path (for Disk):

public class DiskPhysicalPathMapper
{
    private readonly string _physicalRoot;
    private readonly string _urlRoot;

    public DiskPhysicalPathMapper(string urlRoot, string physicalRoot)
    {
        _urlRoot = urlRoot;
        _physicalRoot = physicalRoot;
    }

    public string ConvertUrlToPhysicalPath(string urlPath)
    {
        if (string.IsNullOrEmpty(urlPath))
        {
            throw new Exception($"invalid photosUrlPath: {urlPath}");
        }

        // replace url root with physical root, case insensitive
        var path = Regex.Replace(urlPath, _urlRoot, _physicalRoot, RegexOptions.IgnoreCase);
        return path.Replace(GlobalConstants.UrlSeparator, @"\");
    }
}

I also have a JPegMethaDataAdapter class that I have not included in the review, it just sets JPEG image properties.

I have also not included the S3 infrastructure layer.

\$\endgroup\$
0

4 Answers 4

7
+100
\$\begingroup\$

Architecture

DDD-wise : The most important thing about DDD is that the domain drives the design (so.. DDD). That means that when we look at your domain objects we should be able to understand the whole logic of your application.

Let's summarize what your application is doing : A user posts an product for sale, where the product can have a Youtube link, a website and some photos.

For every noun in there, there should be a corresponding object. Maybe you have them, maybe you don't, I can't tell from the picture you uploaded. The what I can at least tell is that a photo doesn't belong to a user, but to a product (which means that the class UserPhotosPath needs to be re-thought of).

So, your bounded context should look like Product has {Photos, Youtube link, website link} and a user has products.


One very important thing I think you misunderstood about DDD is that the domain is the good place to put the domain logic, not all the logic. Domain logic includes : Relationship between entities, validations. That's... pretty much all. So, you need to ask yourself, does my domain depend on some file system? Do you want your domain, which should be comprised of only domain objects, to know that you use a file system to store your images? That's your problem. You have a hard time to create a domain object named FileInfo because it shouldn't be a domain object.


Finally, Domain Driven Design is kind of a pain to use in a web context, there's one pitfall you should be very aware of that will kill the scalability and performance of your application. The whole bounded context things should be used only when writing to the application. Which means, when you need to show something on your website, have a separate project where all you do is load data in POCOs (plain old C# objects), skipping all business logic, because it doesn't apply to reading.

\$\endgroup\$
4
  • \$\begingroup\$ Thanks, your explanation is really great (simple words). I have 2 bounded context: Domain.Main which holds the product info and Doman.Upload which holds file uploads and images. Reading your answer I guess my choice of creating a bounded context for uploads was a poor one. They fit better in Domain.Main Bounded context which holds Users and Products. \$\endgroup\$ Sep 14, 2019 at 5:24
  • \$\begingroup\$ Regarding your last point, I have my domain Entities which are mapped to ViewModels for the purpose of display. Does it cover your final point? \$\endgroup\$ Sep 14, 2019 at 5:26
  • \$\begingroup\$ 'UserPhotosPath entity feels a bit odd to me too, but it's quite functional. Basically I have a directory for each user (the name of directory is the userId) and under this directory I create one sub directory for each advertisement. Te whole purpose of this entity is to check current logged in user has permission to file upload request (which contains a path and comes from client/browser). Also when I was to create a new advertisement, this entity create a new path under user's path for the advertisement. This entity is always injected into service that need to work with user path. \$\endgroup\$ Sep 14, 2019 at 5:59
  • \$\begingroup\$ @HoomanBahreini Your bounded context should relate to concepts within your application, according to DDD. So Main is probably too large too! Yes your ViewModels should cover this problem. I think you should consider using a Database for your data storage instead of a folder system. Also, consider this : If a user has permission over an ad, it should have permission over the images in it. \$\endgroup\$
    – IEatBagels
    Sep 25, 2019 at 13:14
5
\$\begingroup\$

at first I thought to write this as a comment, perhaps the comment would be a little to long…

There are many things to consider here, not really inline with your question but perhaps you should consider them.

  1. Transactional scope. You update the database, remove records… you may end-up orphaning the disk images where you have images that are not referenced in your database.
  2. Disc capacity, when disks get full you need to stripe them, having a full database is easyer to solve as you can just ad a datafile and have a filegroup span multiple disks
  3. Giving write access to the disk subsystem to your website
  4. Load balancing becomes hard, you will have to start working with mountpoints and add one more point of failure
  5. Disk and operating system performance with large quantities of files degradates, I have an icon repository, when I load it on my PC I loose the ability to preview images.

As to your code, I would consider caching the data where you think it makes sense. At the moment I see no cashing and I guess that this could help reduce some IO, perhaps look at ClientCache and OutputCache.

We ended up storing images in the database and we had a webservice running as a CDN so that browsers make better use of parallel loads and the webservice could better process content and cash the content. Depending on the Trafik and SLA you might get issues, not saying you will, but be aware.

\$\endgroup\$
2
  • \$\begingroup\$ Thanks for this, I am using AWS S3 bucket and (CloudFront) in Production (disk storage is for Dev environment). CloudFront looks after caching (and Load balancing). S3 bucket does not have storage limitations of local disk storage. \$\endgroup\$ Sep 9, 2019 at 12:27
  • \$\begingroup\$ @HoomanBahreini, excellent that's taking care of a few of the issues, I guess you'd need to consider to make a batch file that allows you to clean-up images that are nolonger used. \$\endgroup\$ Sep 9, 2019 at 18:20
5
\$\begingroup\$

Review

Modelling the domain is crucial when designing a multi-layered application. Your concerns about polutting the domain with file or web specific code is pivotal to make a clean, extensible, reusable and testable API.

I am finding it very difficult to think of a domain object which does not include FileInfo or FileStream.


I would suggest the following guidelines for designing your layers.

Domain Entities

  • Use plain and simple classes with public getters and setters.
  • Don't include web or file specific references like FileUpload.FileToUpload which is of type System.Web.HttpPostedFileBase. Instead have a property IReadOnlyCollection<byte> Content and string FileName.

Domain Services

  • Put all configuration and validation logic in services, not in the entities. This way, you can inject configuration and validation behavior at runtime using IoC. Entity UserPhotosPath should have its methods extracted to (for example) a IUserPermissionsValidator and IUserPhotoRepository interface.
  • Perhaps you could provide a IUserPhotoService as a facade for dealing with the validators and repositories internally. Like Entities, Services are a thing in DDD :)
  • Async methods could provide resource consuming behavior: You might want to include a method async Task<IReadOnlyCollection<byte>> ReadContentAsync();

Infrastructure Services/Repositories

  • It is fine for the infrastructure layer to have file and web specific operations. But their public interface should work with the domain classes. Have your services call the infrastructure layer internally. Use the Dependency Inversion Principle to provide the interfaces for the infrastructure layer at the domain layer.

When providing the above layers using the aforementioned patterns and principles, you should be able to provide:

  • DRY code (services with common logic, infrastructure specific implementations)
  • an independant domain (no web, no file specific concerns)
  • DDD using POCO entities and Services
  • Layers communicating through the Dependency Inversion Principle
  • Testable and reusable services using interfaces
\$\endgroup\$
5
  • \$\begingroup\$ Thanks a lot. Using a domain service had never crossed my mind (great suggestion). If I bring the validation logic out of the entity, would I not move toward an anemic domain model? \$\endgroup\$ Sep 12, 2019 at 3:35
  • \$\begingroup\$ That depends on your definition of domain model. I would argue that both the entities and services make up for the model. By moving domain specific behavior to services and keeping the entities simple, I believe you could benefit best from best practices such as dependency injection to allow for easy configuration and testability of the API. \$\endgroup\$
    – dfhwze
    Sep 12, 2019 at 6:39
  • \$\begingroup\$ byte[] properties are discouraged: docs.microsoft.com/en-us/visualstudio/code-quality/… \$\endgroup\$
    – Tseng
    Sep 16, 2019 at 14:46
  • \$\begingroup\$ @Tseng Good point, since these classes aren't DTO's, we should use IEnumerable or IReadOnlyCollection instead. \$\endgroup\$
    – dfhwze
    Sep 17, 2019 at 14:51
  • 1
    \$\begingroup\$ @Tseng we don't have to follow every suggestion comming from Microsoft, they do not follow their own conventions either, here's an example from ASP.NET-Core github.com/aspnet/AspNetCore/blob/… and here a byte array would be much more convenient than other data structures because you can use it directly with streams and the method's name ReadContentAsync looks like it is returning a buffer for/from a stream. \$\endgroup\$
    – t3chb0t
    Sep 17, 2019 at 14:56
4
\$\begingroup\$

Thank you for an interesting question 😊 Here is the first part of the answer, I will publish more over the weekend at https://github.com/dmitrynogin/shopless.

Let’s talk about storage access first. You need to deal with different types of storages, so let’s abstract:

public abstract class Folder : Enumerable<string>
{
    public static Folder Open(Connection @string) => 
        @string.Open<Folder>();

    public abstract Stream Write(string file);
    public abstract Stream Read(string file);
    public abstract void Delete(string file);
}

Connection string allows you to resolve concrete storage type and content location at run-time using stringly typed configuration parameter. It adds a lot of flexibility when you are testing, even allows to combine different storages.

Connection looks like this:

public sealed class Connection : ValueObject<Connection>
{
    public static implicit operator Connection(string @string) => Parse(@string);
    public static Connection Parse(string @string) => new Connection(@string);            

    Connection(string @string)
    {
        Values = @string.Split(';')
           .Select(nv => nv.Split('='))
           .ToDictionary(
                nv => nv[0].Trim(), 
                nv => string.Join("=", nv.Skip(1)).Trim());
    }

    IReadOnlyDictionary<string, string> Values { get; }

    public T Get<T>(string name, T @default = default(T)) =>
        Values.TryGetValue(name, out var value) 
        ? (T)TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(value)
        : @default;

    public T Open<T>() => (T)Activator.CreateInstance(
        Type.GetType(Get<string>("Type")), this);

    protected override IEnumerable<object> EqualityCheckAttributes => 
        new object[] { ToString() };

    public override string ToString() => 
        string.Join(";", from nv in Values
                         select $"{nv.Key}={nv.Value}");
}

Where concrete disk folder implementation could look like the following:

public sealed class DiskFolder : Folder
{
    public static Connection String(string path) => 
        $"Type={typeof(DiskFolder).FullName}, {typeof(DiskFolder).Assembly.GetName().Name};" + 
        $"Path={path}";

    public DiskFolder()
        : this(GetTempPath())
    {
    }

    public DiskFolder(Connection @string)
        : this(@string.Get<string>("Path"))
    {
    }

    public DiskFolder(string path) 
    {
        Path = path ?? throw new ArgumentNullException(nameof(path));
        if (!Exists(Path))
            CreateDirectory(Path);
    }

    public string Path { get; }

    public override IEnumerator<string> GetEnumerator() => 
        EnumerateFiles(Path).Select(GetFileName).GetEnumerator();

    public override Stream Write(string file) => 
        File.OpenWrite(Combine(Path, file));

    public override Stream Read(string file) =>
        File.OpenRead(Combine(Path, file));

    public override void Delete(string file) =>
        File.Delete(Combine(Path, file));
}

The home work would be to create S3BucketFolder – just pack all the required parameters into the connection string.

Helpers to be as DRY as possible are:

public abstract class Enumerable<T> : IEnumerable<T>
{
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public abstract IEnumerator<T> GetEnumerator();
}

And:

public abstract class ValueObject<T> : IEquatable<ValueObject<T>>
    where T : ValueObject<T>
{
    protected abstract IEnumerable<object> EqualityCheckAttributes { get; }

    public override int GetHashCode() =>
        EqualityCheckAttributes
            .Aggregate(0, (hash, a) => unchecked(hash * 31 + (a?.GetHashCode() ?? 0)));

    public override bool Equals(object obj) =>
        Equals(obj as ValueObject<T>);

    public virtual bool Equals(ValueObject<T> other) =>
        other != null &&
        GetType() == other.GetType() &&
        EqualityCheckAttributes.SequenceEqual(other.EqualityCheckAttributes);

    public static bool operator ==(ValueObject<T> left, ValueObject<T> right) =>
        Equals(left, right);

    public static bool operator !=(ValueObject<T> left, ValueObject<T> right) =>
        !Equals(left, right);
}

Please note that all projects in the solution have the same default namespace “Shopless” to reduce amount of required using directives to the minimum.

You could test the folder:

    [TestMethod]
    public void Stringify()
    {
        Connection connection = DiskFolder.String("c:\\proj");
        Assert.AreEqual(
            "Type=Shopless.IO.DiskFolder, Shopless.Disk;Path=c:\\proj", 
            $"{connection}");
    }

And:

    [TestMethod]
    public void Open()
    {
        Folder folder = Folder.Open("Type=Shopless.IO.DiskFolder, Shopless.Disk;Path=c:\\proj");
        Assert.IsTrue(folder is DiskFolder df && df.Path == "c:\\proj");
    }

And:

    [TestMethod]
    public void Manage_Files()
    {
        var folder = new DiskFolder();
        folder.Delete("test.txt");
        Assert.IsFalse(folder.Contains("test.txt"));
        folder.Write("test.txt").Dispose();
        folder.Read("test.txt").Dispose();
        Assert.IsTrue(folder.Contains("test.txt"));
        folder.Delete("test.txt");
        Assert.IsFalse(folder.Contains("test.txt"));
    }

More stuff is coming :)

UPDATE

Let’s now define a configuration mechanism and use it:

public class Config : ValueObject<Config>
{        
    public static readonly Config Local = Load("local.json");
    public static readonly Config Stage = Load("stage.json");
    public static readonly Config Published = Load("published.json");
    public static readonly Config Active = Published ?? Stage ?? Local;

    public static Config Load(string file) =>
        Exists(file) ? JsonConvert.DeserializeObject<Config>(ReadAllText(file)) : null;

    public Config(string uploadFolder, string imageFolder)
    {
        UploadFolder = uploadFolder;
        ImageFolder = imageFolder;
    }

    public Connection UploadFolder { get; }
    public Connection ImageFolder { get; }

    protected override IEnumerable<object> EqualityCheckAttributes =>
        new object[] { UploadFolder, ImageFolder };
}

Now we will have IStorage to represent folder set across the solution to inject where folder access is required:

public interface IStorage
{
    Folder Upload { get; }
    Folder Images { get; } 
}

My default implementation uses active configuration:

[Service]
public class Storage : IStorage
{
    public Folder Upload => Folder.Open(Config.Active.UploadFolder);
    public Folder Images => Folder.Open(Config.Active.ImageFolder);
}

Have a look at Shopless.Autofac and Demo projects to see how ServiceAttribute works.

Now it is time to define our service according to the first paragraph of your question:

public interface IImageUploader
{
    string Upload(int number, Stream jpeg);
    void Rename(string product, params string[] images);
}

Where the implementation would be:

[Service]
public class ImageUploader : IImageUploader
{
    public ImageUploader(IStorage storage)
    {
        Storage = storage ?? throw new ArgumentNullException(nameof(storage));
    }

    IStorage Storage { get; }

    public string Upload(int number, Stream jpeg)
    {
        var name = TemporaryImageName.New(number);
        using (var file = Storage.Upload.Write(name))
            jpeg.CopyTo(file);

        return name;
    }

    public void Rename(string product, params string[] images)
    {
        var pn = ProductName.New(product);
        foreach (var tin in images.Select(TemporaryImageName.Parse))
            if(Storage.Upload.Contains(tin))
            {
                var pin = ProductImageName.New(tin, pn);
                Storage.Upload.MoveTo(Storage.Images, tin, pin);
            }
    }
}

We will need:

public class ProductName : ValueObject<ProductName>
{
    public static ProductName New(string text) =>
        new ProductName(text.Trim(), Slug.New(text));

    public ProductName(string text, Slug slug)
    {
        Text = text ?? throw new ArgumentNullException(nameof(text));
        Slug = slug ?? throw new ArgumentNullException(nameof(slug));
    }

    public string Text { get; }
    public Slug Slug { get; }

    protected override IEnumerable<object> EqualityCheckAttributes => 
        new object[] { Slug };
}

And:

public class Slug : ValueObject<Slug>
{
    public static Slug New(string text) =>
        new Slug(text
            .Trim()
            .Replace(" ", "-")
            .ToLowerInvariant());

    public static Slug Parse(string text) =>
        TryParse(text, out var slug) ? slug : throw new FormatException("Malformed slug.");

    public static bool TryParse(string text, out Slug slug) =>
        (slug = IsNullOrWhiteSpace(text) ? null : new Slug(text)) != null;

    public Slug(string text)
    {
        Text = text;
    }

    public string Text { get; }

    public override string ToString() => Text;

    protected override IEnumerable<object> EqualityCheckAttributes =>
        new object[] { Text };
}

And:

public class TemporaryImageName : ValueObject<TemporaryImageName>
{
    public static implicit operator string(TemporaryImageName name) => $"{name}";

    public static TemporaryImageName New(int number) =>
        new TemporaryImageName(number, Clock.Time());

    public static TemporaryImageName Parse(string text) =>
        GetExtension(text) == ".jpg" &&
        GetFileNameWithoutExtension(text).Split('-') is string[] p &&
        p.Length == 3 && p[0] == "tmp" &&
        int.TryParse(p[1], out var n) &&
        DateTime.TryParseExact(p[2], "yyyyMMddHHmmssfff", null, DateTimeStyles.None, out var t)
        ? new TemporaryImageName(n, t)
        : throw new FormatException("Invalid temporary image name.");

    public TemporaryImageName(int number, DateTime timestamp)
    {
        Number = number;
        Timestamp = timestamp;
    }

    public int Number { get; }
    public DateTime Timestamp { get; }

    public override string ToString() => $"tmp-{Number}-{Timestamp:yyyyMMddHHmmssfff}.jpg";

    protected override IEnumerable<object> EqualityCheckAttributes =>
        new object[] { Number, Timestamp };
}

And:

public class ProductImageName : ValueObject<ProductImageName>
{
    public static implicit operator string(ProductImageName name) => $"{name}";

    public static ProductImageName New(TemporaryImageName imageName, ProductName productName) => 
        new ProductImageName(imageName.Number, imageName.Timestamp, productName.Slug);

    public static ProductImageName Parse(string text) =>
        GetExtension(text) == ".jpg" &&
        GetFileNameWithoutExtension(text).Split('-') is string[] p &&
        p.Length == 4 && p[0] == "tmp" &&
        int.TryParse(p[1], out var n) &&
        DateTime.TryParseExact(p[2], "yyyyMMddHHmmssfff", null, DateTimeStyles.None, out var t) &&
        Slug.TryParse(p[3], out var slug)             
        ? new ProductImageName(n, t, slug)
        : throw new FormatException("Invalid product image name.");

    public ProductImageName(int number, DateTime timestamp, Slug product)
    {
        Number = number;
        Timestamp = timestamp;
        Product = product ?? throw new ArgumentNullException(nameof(product));
    }

    public int Number { get; }
    public DateTime Timestamp { get; }
    public Slug Product { get; }

    public override string ToString() => $"img-{Number}-{Timestamp:yyyyMMddHHmmssfff}-{Product}.jpg";

    protected override IEnumerable<object> EqualityCheckAttributes => 
        new object[] { Number, Timestamp, Product };
}

Please note that we provide persistability of those types by allowing data flow unmodified through public constructor to public readonly properties. We should use a static factory method to produce a new Slug, etc. This way you are safe to modify factory behavior without invalidating the persistent state when/where available.

The integration test would be:

[TestClass]
public class ImageUploader_Should
{
    [TestMethod]
    public void Upload()
    {
        Clock.Time = () => new DateTime(2019, 1, 2, 3, 4, 5, 678);

        IStorage storage = new Storage();
        storage.Upload.Clear();
        storage.Images.Clear();

        IImageUploader iu = new ImageUploader(storage);

        var tin1 = iu.Upload(1, new MemoryStream());
        var tin2 = iu.Upload(2, new MemoryStream());

        iu.Rename("Best Pasta Ever", tin1, tin2);

        Assert.IsFalse(storage.Upload.Any());

        Assert.AreEqual(2, storage.Images.Count());
        Assert.IsTrue(storage.Images.Contains("img-1-20190102030405678-best-pasta-ever.jpg"));
        Assert.IsTrue(storage.Images.Contains("img-2-20190102030405678-best-pasta-ever.jpg"));
    }
}

You can see here a use of ambient system time property which is part of our domain logic:

public class Clock
{
    public static Func<DateTime> Time { get; set; } = () => DateTime.Now;
}

Please feel free to ping me in skype if you have any questions: dmitrynogin

\$\endgroup\$
1
  • \$\begingroup\$ Comments are not for extended discussion; this conversation has been moved to chat. \$\endgroup\$
    – Vogel612
    Sep 16, 2019 at 17:59

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.